9dcb0f91d0
Upgrades pypetkitapi to 1.28.0, which adds the Eversweet Ultra (W7H fountain with AI camera). The poller now extracts per-pet drinking events (drink_over/pet_detect records) into a new drinking_events table, plus Ultra-specific alerts for waste tank full and filter expiry. Uncertain state fields (cwt/wt/pump) are logged for threshold tuning against real data. Exposes the data via a new /api/pets/drinking-events endpoint, a Fountain Activity dashboard section, and a simbakit_drinking_activity LangChain tool so the agent can answer hydration questions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
199 lines
7.1 KiB
Python
199 lines
7.1 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)
|
|
|
|
async def get_drinking_events(
|
|
self, pet_name: Optional[str] = None, days: Optional[int] = None
|
|
) -> str:
|
|
"""Get water fountain drinking 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/drinking-events", params=params or None)
|
|
events = data.get("events", [])
|
|
stats = data.get("stats", {})
|
|
|
|
if not events and not stats.get("total_drinks"):
|
|
return "No water fountain activity found."
|
|
|
|
lines = [
|
|
f"Water Fountain Activity ({stats.get('total_drinks', 0)} drinking sessions):",
|
|
]
|
|
|
|
avg_duration = stats.get("avg_duration", 0)
|
|
if avg_duration:
|
|
lines.append(f"- Average drink duration: {avg_duration:.0f}s")
|
|
|
|
detections = stats.get("detections", 0)
|
|
if detections:
|
|
lines.append(f"- Camera detections (visits without drinking): {detections}")
|
|
|
|
per_pet = stats.get("per_pet", [])
|
|
if len(per_pet) > 1:
|
|
lines.append("- By pet:")
|
|
for p in per_pet:
|
|
lines.append(f" - {p['pet_name']}: {p['drinks']} drinks")
|
|
|
|
# Recent events
|
|
if events:
|
|
lines.append("\nRecent events:")
|
|
for e in events[:5]:
|
|
pet = e.get("pet_name", "Unknown")
|
|
time = e.get("recorded_at", "N/A")
|
|
if e.get("record_type") == "drink_over":
|
|
duration = f" for {e['duration_s']}s" if e.get("duration_s") else ""
|
|
lines.append(f"- {pet} drank at {time}{duration}")
|
|
else:
|
|
lines.append(f"- {pet} visited the fountain at {time}")
|
|
|
|
return "\n".join(lines)
|