diff --git a/aria-brain/background.py b/aria-brain/background.py index c2e131f..d57111e 100644 --- a/aria-brain/background.py +++ b/aria-brain/background.py @@ -27,7 +27,12 @@ import watcher as watcher_mod logger = logging.getLogger(__name__) -TICK_SEC = 30 +# Polling-Frequenz des Background-Loops. Vorher 30s → Auto-Vorbeifahrt +# durch einen 300m-Radius bei >50 km/h konnte zwischen zwei Ticks komplett +# verpasst werden. Mit 8s ist auch eine 18-Sekunden-Durchfahrt (120 km/h +# durch 300m) garantiert mind. einmal getroffen. Der Loop ist billig +# (paar Dateilesungen + AST-Eval), das macht Brain nicht warm. +TICK_SEC = 8 BRIDGE_URL = os.environ.get("BRIDGE_URL", "http://aria-bridge:8090") @@ -195,8 +200,31 @@ async def _tick(agent_factory) -> None: logger.warning("Trigger-Check %s: %s", trigger.get("name"), e) +# Module-Level-Slot fuer die agent_factory damit on-demand-Ticks (von +# z.B. POST /triggers/check-now) Zugang haben ohne durch den ganzen +# Lifespan-Pfad geschleust zu werden. +_AGENT_FACTORY = None + + +async def tick_now() -> dict: + """Sofortiger Trigger-Check — nicht warten auf den naechsten Loop-Tick. + Wird genutzt wenn ein neues GPS-Update reinkommt: Bridge ruft das nach + _persist_location, damit Watcher mit near() den frischen Wert sofort + sehen statt bis zu TICK_SEC Sekunden zu warten.""" + if _AGENT_FACTORY is None: + return {"ok": False, "error": "Background-Loop noch nicht gestartet"} + try: + await _tick(_AGENT_FACTORY) + return {"ok": True} + except Exception as exc: + logger.exception("tick_now: %s", exc) + return {"ok": False, "error": str(exc)} + + async def run_loop(agent_factory) -> None: """Endlosschleife — wird vom main lifespan gestartet + gestoppt.""" + global _AGENT_FACTORY + _AGENT_FACTORY = agent_factory logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC) while True: try: diff --git a/aria-brain/main.py b/aria-brain/main.py index 6b5f681..faa7231 100644 --- a/aria-brain/main.py +++ b/aria-brain/main.py @@ -657,6 +657,16 @@ def triggers_list(active_only: bool = False): return {"triggers": triggers_mod.list_triggers(active_only=active_only)} +@app.post("/triggers/check-now") +async def triggers_check_now(): + """Sofortiger Trigger-Check, statt auf den naechsten Background-Tick + zu warten. Wird von der Bridge nach jedem location_update gerufen + damit GPS-Watcher (near()) den frischen Wert SOFORT sehen — bei + Auto-Vorbeifahrt durch einen 300m-Radius hat man sonst nur ~20s + Drinnen-Zeit, was unter TICK_SEC fallen kann.""" + return await background_mod.tick_now() + + @app.get("/triggers/conditions") def triggers_conditions(): """Verfuegbare Variablen + Funktionen fuer Watcher-Conditions diff --git a/aria-brain/watcher.py b/aria-brain/watcher.py index ce22316..ed9faab 100644 --- a/aria-brain/watcher.py +++ b/aria-brain/watcher.py @@ -91,6 +91,12 @@ def _cpu_load_1min() -> float: _DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] +# Maximales GPS-Alter fuer near()-Auswertung. Wenn die App laenger nicht +# gepushed hat (z.B. Tracking aus, Mobilfunk weg, App geschlossen), gilt +# die Position als "unbekannt" und near() liefert False — verhindert +# Phantom-Fires basierend auf einer wochen-alten Position. +NEAR_MAX_AGE_SEC = 5 * 60 + def _gps_state() -> dict[str, Any]: """Letzte bekannte Position aus /shared/state/location.json. @@ -177,11 +183,18 @@ def collect_variables() -> dict[str, Any]: # Funktion-Helper — wird vom Parser als ast.Call mit Name "near" erkannt. # Closure ueber die GPS-Werte, damit eval keine extra Variablen braucht. def _near(lat: float, lon: float, radius_m: float) -> bool: - """Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt.""" + """Haversine-Distanz: True wenn aktuelle Position < radius_m vom Punkt. + Plus Age-Schutz: GPS-Daten aelter als NEAR_MAX_AGE_SEC werden als + veraltet betrachtet → False. Sonst wuerde near() bei abgeschaltetem + Tracking weiter die letzte bekannte Position nutzen und potentiell + Phantom-Fires werfen wenn der User mal in der Naehe war.""" cur_lat = vars_.get("current_lat") cur_lon = vars_.get("current_lon") if cur_lat is None or cur_lon is None: return False + age = vars_.get("location_age_sec") + if isinstance(age, (int, float)) and age >= 0 and age > NEAR_MAX_AGE_SEC: + return False try: R = 6371000.0 phi1 = math.radians(float(cur_lat)) diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 90cabcd..4e16c42 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -938,7 +938,12 @@ class ARIABridge: def _persist_location(self, location: Optional[dict]) -> None: """Speichert die letzte bekannte GPS-Position fuer Watcher. Erwartet {lat, lon} oder {lat, lng}. Nicht-Dicts und fehlende - Koordinaten werden ignoriert.""" + Koordinaten werden ignoriert. + + Plus: triggert sofort einen on-demand Trigger-Check im Brain + (POST /triggers/check-now). Ohne das wartet der Watcher-Loop + bis zu TICK_SEC Sekunden — bei Auto-Vorbeifahrt durch einen + 300m-Radius (18-43s drin) kann das den Trigger verpassen.""" if not isinstance(location, dict): return try: @@ -950,9 +955,31 @@ class ARIABridge: "lat": float(lat), "lon": float(lon), }) + except Exception: + return + # Fire-and-forget: Brain-on-demand-Tick. Wenn Brain nicht antwortet + # oder langsam ist, blockt das nicht den GPS-Pfad. + try: + asyncio.create_task(self._trigger_brain_check_now()) except Exception: pass + async def _trigger_brain_check_now(self) -> None: + """Brain-Endpoint POST /triggers/check-now anstossen.""" + brain_url = os.environ.get("BRAIN_URL", "http://aria-brain:8080") + def _post(): + try: + req = urllib.request.Request( + f"{brain_url}/triggers/check-now", + data=b"", method="POST", + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=8) as r: + return r.status + except Exception: + return None + await asyncio.get_event_loop().run_in_executor(None, _post) + def _persist_user_activity(self) -> None: """Markiert dass der User gerade etwas gemacht hat (Chat/Voice). Watcher: last_user_message_ago_sec basiert darauf."""