01969b2d24
Adds simbakit/ as a subfolder service that polls PetKit smart devices (litter box, fountain, feeder) and exposes pet weight, litter activity, and device health data via three new LangChain tools. Also fixes stale alerts bug where cleared conditions (e.g. waste bin full) persisted because the query used max alert time instead of the device's last_seen_at timestamp. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
150 lines
5.3 KiB
Python
150 lines
5.3 KiB
Python
"""SimbaKit API service for querying PetKit device and pet data."""
|
|
|
|
import os
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
import aiohttp
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SimbaKitService:
|
|
"""Service for interacting with SimbaKit API."""
|
|
|
|
def __init__(self):
|
|
self.base_url = os.getenv("SIMBAKIT_URL", "")
|
|
if not self.base_url:
|
|
raise ValueError("SIMBAKIT_URL environment variable is required")
|
|
self.base_url = self.base_url.rstrip("/")
|
|
|
|
async def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(f"{self.base_url}{path}", params=params) as resp:
|
|
resp.raise_for_status()
|
|
return await resp.json()
|
|
|
|
async def get_devices(self) -> str:
|
|
"""Get all devices with latest status."""
|
|
devices = await self._get("/api/devices")
|
|
if not devices:
|
|
return "No PetKit devices found."
|
|
|
|
lines = []
|
|
for d in devices:
|
|
snap = d.get("latest_snapshot", {})
|
|
error = snap.get("error_code")
|
|
status = f"Error {error}: {snap.get('error_msg', '')}" if error else "OK"
|
|
lines.append(
|
|
f"- {d.get('name', d['device_id'])} ({d.get('device_type', 'unknown')}): "
|
|
f"Status={status}, Firmware={d.get('firmware', 'N/A')}, "
|
|
f"Last seen={d.get('last_seen_at', 'N/A')}"
|
|
)
|
|
return "PetKit Devices:\n" + "\n".join(lines)
|
|
|
|
async def get_alerts(self, device_id: Optional[str] = None) -> str:
|
|
"""Get active alerts, optionally filtered by device."""
|
|
if device_id:
|
|
alerts = await self._get(f"/api/devices/{device_id}/alerts")
|
|
else:
|
|
alerts = await self._get("/api/alerts")
|
|
|
|
if not alerts:
|
|
return "No active alerts."
|
|
|
|
lines = []
|
|
for a in alerts:
|
|
device_name = a.get("device_name", a.get("device_id", "unknown"))
|
|
lines.append(
|
|
f"- [{a['severity'].upper()}] {a['alert_label']} "
|
|
f"(device: {device_name}, at: {a.get('polled_at', 'N/A')})"
|
|
)
|
|
return "Active Alerts:\n" + "\n".join(lines)
|
|
|
|
async def get_pet_weights(
|
|
self, pet_name: Optional[str] = None, days: Optional[int] = None
|
|
) -> str:
|
|
"""Get pet weight records."""
|
|
params: dict[str, str] = {}
|
|
if pet_name:
|
|
params["pet"] = pet_name
|
|
if days:
|
|
params["days"] = str(days)
|
|
|
|
data = await self._get("/api/pets/weights", params=params or None)
|
|
weights = data.get("weights", [])
|
|
|
|
if not weights:
|
|
return "No weight records found."
|
|
|
|
# Summary stats
|
|
weight_values = [w["weight_g"] for w in weights]
|
|
latest = weights[-1]
|
|
avg_g = sum(weight_values) / len(weight_values)
|
|
|
|
lines = [
|
|
f"Pet Weight Data ({len(weights)} records):",
|
|
f"- Latest: {latest.get('pet_name', 'Unknown')} - {latest['weight_g'] / 1000:.2f} kg at {latest['recorded_at']}",
|
|
f"- Average: {avg_g / 1000:.2f} kg",
|
|
f"- Range: {min(weight_values) / 1000:.2f} - {max(weight_values) / 1000:.2f} kg",
|
|
]
|
|
|
|
# Trend from last 5 vs previous 5
|
|
if len(weights) >= 10:
|
|
recent = sum(w["weight_g"] for w in weights[-5:]) / 5
|
|
previous = sum(w["weight_g"] for w in weights[-10:-5]) / 5
|
|
diff = recent - previous
|
|
if abs(diff) > 10:
|
|
direction = "gaining" if diff > 0 else "losing"
|
|
lines.append(f"- Trend: {direction} ({diff / 1000:+.2f} kg)")
|
|
else:
|
|
lines.append("- Trend: stable")
|
|
|
|
return "\n".join(lines)
|
|
|
|
async def get_litter_events(
|
|
self, pet_name: Optional[str] = None, days: Optional[int] = None
|
|
) -> str:
|
|
"""Get litter box activity events and stats."""
|
|
params: dict[str, str] = {}
|
|
if pet_name:
|
|
params["pet"] = pet_name
|
|
if days:
|
|
params["days"] = str(days)
|
|
|
|
data = await self._get("/api/pets/litter-events", params=params or None)
|
|
events = data.get("events", [])
|
|
stats = data.get("stats", {})
|
|
|
|
if not events and not stats.get("total_visits"):
|
|
return "No litter box activity found."
|
|
|
|
lines = [
|
|
f"Litter Box Activity ({stats.get('total_visits', 0)} visits):",
|
|
f"- Poops: {stats.get('poop_count', 0)}",
|
|
f"- Pees: {stats.get('pee_count', 0)}",
|
|
]
|
|
|
|
avg_time = stats.get("avg_toilet_time", 0)
|
|
if avg_time:
|
|
lines.append(f"- Average time in box: {avg_time:.0f}s")
|
|
|
|
avg_weight = stats.get("avg_shit_weight", 0)
|
|
if avg_weight:
|
|
lines.append(f"- Average poop weight: {avg_weight:.0f}g")
|
|
|
|
# Recent events
|
|
if events:
|
|
lines.append("\nRecent events:")
|
|
for e in events[:5]:
|
|
event_type = "Pooped" if e.get("is_shit") == 1 else "Peed"
|
|
pet = e.get("pet_name", "Unknown")
|
|
time = e.get("recorded_at", "N/A")
|
|
duration = f", {e['toilet_time_s']}s" if e.get("toilet_time_s") else ""
|
|
lines.append(f"- {pet} {event_type} at {time}{duration}")
|
|
|
|
return "\n".join(lines)
|