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:
hermes-home
2026-08-04 16:02:45 +00:00
parent b5c497530d
commit dace7ab46c
4 changed files with 218 additions and 0 deletions

3
.gitignore vendored
View File

@@ -3,6 +3,9 @@
secrets/
*.token
# Runtime состояние
.food_state
# Python
__pycache__/
*.pyc

94
petkit_food_check.py Normal file
View 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
View 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
View 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())