Add PetKit food watchdog + D4H raw probes
- petkit_food_check.py: watchdog для state.food (алярм мало/пусто, сброс при доливе) - petkit_raw.py, petkit_roster.py: прямые запросы к API для отладки - .gitignore: исключить .food_state (runtime состояние)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,9 @@
|
|||||||
secrets/
|
secrets/
|
||||||
*.token
|
*.token
|
||||||
|
|
||||||
|
# Runtime состояние
|
||||||
|
.food_state
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
94
petkit_food_check.py
Normal file
94
petkit_food_check.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PetKit food watchdog — проверяет уровень корма в фидере.
|
||||||
|
|
||||||
|
Поведение:
|
||||||
|
- Читает state.food текущего фидера.
|
||||||
|
- Хранит последний известный уровень в state-файле.
|
||||||
|
- Печатает в stdout уведомление ТОЛЬКО при переходе в "мало"(1) или "пусто"(0),
|
||||||
|
либо когда корм снова долили (возврат к полному) — чтобы сбросить тревогу.
|
||||||
|
- При первом запуске просто запоминает состояние, ничего не печатает.
|
||||||
|
|
||||||
|
Предназначен для крона в watchdog-режиме:
|
||||||
|
- непустой stdout -> уведомление доставляется пользователю
|
||||||
|
- пустой stdout -> тихий прогон, никому ничего не шлём
|
||||||
|
"""
|
||||||
|
import asyncio, os, sys
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
from petkitaio import PetKitClient
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
STATE_FILE = os.path.join(HERE, ".food_state")
|
||||||
|
|
||||||
|
# Уровни корма у D4H: 2 = достаточно, 1 = мало, 0 = пусто
|
||||||
|
# (порог можно скорректировать после наблюдения за реальными значениями)
|
||||||
|
ALERT_AT = 1 # алертим когда food <= 1
|
||||||
|
|
||||||
|
|
||||||
|
def load_env():
|
||||||
|
p = os.path.join(HERE, ".env")
|
||||||
|
if os.path.exists(p):
|
||||||
|
for line in open(p):
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
k, _, v = line.partition("=")
|
||||||
|
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||||
|
|
||||||
|
|
||||||
|
def read_state():
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE) as f:
|
||||||
|
return int(f.read().strip())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_state(v):
|
||||||
|
with open(STATE_FILE, "w") as f:
|
||||||
|
f.write(str(v))
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
load_env()
|
||||||
|
email = os.environ.get("PETKIT_EMAIL")
|
||||||
|
password = os.environ.get("PETKIT_PASSWORD")
|
||||||
|
region = os.environ.get("PETKIT_REGION", "Russian Federation")
|
||||||
|
if not email or not password:
|
||||||
|
sys.exit("ОШИБКА: нет PETKIT_EMAIL/PETKIT_PASSWORD в .env")
|
||||||
|
|
||||||
|
s = ClientSession()
|
||||||
|
c = PetKitClient(email, password, s, region)
|
||||||
|
try:
|
||||||
|
await c.login()
|
||||||
|
devs = await c.get_petkit_data()
|
||||||
|
feeders = devs.feeders or {}
|
||||||
|
if not feeders:
|
||||||
|
sys.exit(0) # фидер не виден — молчим
|
||||||
|
fid = list(feeders.keys())[0]
|
||||||
|
f = feeders[fid]
|
||||||
|
food = f.data.get("state", {}).get("food")
|
||||||
|
name = f.data.get("name", fid)
|
||||||
|
if food is None:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
prev = read_state()
|
||||||
|
write_state(food)
|
||||||
|
|
||||||
|
# Тревога "мало/пусто" — только при переходе именно в это состояние
|
||||||
|
if prev is not None and prev > ALERT_AT and food <= ALERT_AT:
|
||||||
|
state_txt = "ПОЧТИ ПУСТО" if food == 0 else "НА ИСХОДЕ"
|
||||||
|
print(
|
||||||
|
f"\U0001f9d4 Фидер «{name}»: КОРМ {state_txt} (уровень {food}). "
|
||||||
|
f"Проверь и долей корм."
|
||||||
|
)
|
||||||
|
# Сброс тревоги — корм снова долили
|
||||||
|
elif prev is not None and prev <= ALERT_AT and food > ALERT_AT:
|
||||||
|
print(f"🟢 Фидер «{name}»: корм долили (уровень {food}). Тревога сброшена.")
|
||||||
|
|
||||||
|
# Остальное — тихо
|
||||||
|
finally:
|
||||||
|
await s.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
75
petkit_raw.py
Normal file
75
petkit_raw.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Прямой запрос к PetKit API — сырой вывод без обработки библиотекой."""
|
||||||
|
import asyncio, hashlib, json, os
|
||||||
|
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def load_env():
|
||||||
|
p = os.path.join(HERE, ".env")
|
||||||
|
if os.path.exists(p):
|
||||||
|
for line in open(p):
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
k, _, v = line.partition("=")
|
||||||
|
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||||
|
|
||||||
|
|
||||||
|
REGION_URL = "https://passport.petkt.com/6/account/regionservers"
|
||||||
|
USER_AGENT = "PETKIT/11.2.1 (iPhone; iOS 15.1; Scale/3.00)"
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
load_env()
|
||||||
|
email = os.environ["PETKIT_EMAIL"]
|
||||||
|
password = os.environ["PETKIT_PASSWORD"]
|
||||||
|
region = os.environ.get("PETKIT_REGION", "Russian Federation")
|
||||||
|
|
||||||
|
hdr = {
|
||||||
|
"Accept": "*/*",
|
||||||
|
"Accept-Language": "en-US;q=1, it-US;q=0.9",
|
||||||
|
"Accept-Encoding": "gzip, deflate",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
"X-Client": "ios(15.1;iPhone14,3)",
|
||||||
|
}
|
||||||
|
|
||||||
|
async with ClientSession() as s:
|
||||||
|
# 1. список региональных серверов
|
||||||
|
r = await s.post(REGION_URL, headers=hdr, data={})
|
||||||
|
sl = (await r.json())["result"]["list"]
|
||||||
|
region_id = None
|
||||||
|
base_url = None
|
||||||
|
for reg in sl:
|
||||||
|
if reg["name"] == region:
|
||||||
|
region_id = reg["id"]
|
||||||
|
base_url = reg["gateway"]
|
||||||
|
print("=== REGION ===", region, "-> id", region_id, base_url)
|
||||||
|
|
||||||
|
login_url = f"{base_url}user/login"
|
||||||
|
data = {
|
||||||
|
"client": '{"deviceModel":"iPhone14,3","systemVersion":"15.1","appVersion":"11.2.1","lang":"en","timezone":"Asia/Yekaterinburg"}',
|
||||||
|
"encrypt": "1",
|
||||||
|
"oldVersion": "11.2.1",
|
||||||
|
"password": hashlib.md5(password.encode()).hexdigest(),
|
||||||
|
"region": str(region_id),
|
||||||
|
"username": email,
|
||||||
|
}
|
||||||
|
r = await s.post(login_url, headers=hdr, data=data)
|
||||||
|
login = await r.json()
|
||||||
|
print("=== LOGIN ===")
|
||||||
|
print(json.dumps(login, ensure_ascii=False, indent=2))
|
||||||
|
token = login["result"]["session"]["id"]
|
||||||
|
hdr["X-Session"] = token
|
||||||
|
|
||||||
|
# 2. список устройств (device roster) — сырой
|
||||||
|
roster_url = f"{base_url}discovery/device_roster_v2"
|
||||||
|
r = await s.post(roster_url, headers=hdr, data={})
|
||||||
|
roster = await r.json()
|
||||||
|
print("\n=== DEVICE ROSTER (сырой) ===")
|
||||||
|
print(json.dumps(roster, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
46
petkit_roster.py
Normal file
46
petkit_roster.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Сырой вывод PetKit device roster (что реально возвращает API), без парсинга."""
|
||||||
|
import asyncio, json, os
|
||||||
|
from datetime import datetime
|
||||||
|
from aiohttp import ClientSession
|
||||||
|
from petkitaio import PetKitClient
|
||||||
|
from petkitaio.constants import Endpoint
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def load_env():
|
||||||
|
p = os.path.join(HERE, ".env")
|
||||||
|
for line in open(p):
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and "=" in line:
|
||||||
|
k, _, v = line.partition("=")
|
||||||
|
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
load_env()
|
||||||
|
s = ClientSession()
|
||||||
|
c = PetKitClient(
|
||||||
|
os.environ["PETKIT_EMAIL"],
|
||||||
|
os.environ["PETKIT_PASSWORD"],
|
||||||
|
s,
|
||||||
|
os.environ.get("PETKIT_REGION", "Russian Federation"),
|
||||||
|
)
|
||||||
|
await c.login()
|
||||||
|
print("=== AUTH ===")
|
||||||
|
print("user_id:", c.user_id, " group_ids:", list(c.group_ids), " base:", c.base_url)
|
||||||
|
|
||||||
|
url = f"{c.base_url}{Endpoint.DEVICE_ROSTER}"
|
||||||
|
hdr = await c.create_header()
|
||||||
|
day = str(datetime.now().date()).replace("-", "")
|
||||||
|
print(f"\n=== RAW DEVICE ROSTER (day={day}) ===")
|
||||||
|
for gid in c.group_ids:
|
||||||
|
data = {"day": day, "groupId": gid}
|
||||||
|
resp = await c._post(url, hdr, data)
|
||||||
|
print(f"--- group {gid} ---")
|
||||||
|
print(json.dumps(resp, ensure_ascii=False, indent=2))
|
||||||
|
await s.close()
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user