From 6f80e442cfd61ccd00ebb91a0cff60c616c11fa6 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 14 May 2026 18:16:53 +0200 Subject: [PATCH] =?UTF-8?q?fix(trigger):=20near()=20fired=20bei=20Auto-Vor?= =?UTF-8?q?beifahrten=20verpasst=20=E2=80=94=20Loop=20schneller=20+=20even?= =?UTF-8?q?t-getrieben?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stefan ist mehrmals an einem 300m-near()-Watcher (DRK Kreyenbrueck) vorbeigefahren, kein Fire. Ursache: Background-Loop tickte alle 30s, Auto-Durchfahrt durch 600m-Durchmesser-Radius dauert bei 50-120 km/h nur 18-43 Sekunden — der Tick konnte komplett dazwischen liegen. Drei Fixes (A + B aus Stefans Vorschlag): A1. Background-Loop-Frequenz: TICK_SEC 30 → 8. Garantiert mind. 2 Checks auch bei 120 km/h durch 300m. Loop ist billig (paar Dateilesungen + AST-Eval), Brain merkt das nicht. A2. near() bekommt Age-Schutz (watcher.py NEAR_MAX_AGE_SEC=300): Wenn location_age_sec > 5 min, gilt die Position als unbekannt und near() liefert False. Verhindert Phantom-Fires wenn Tracking aus ist oder Mobilfunk weg war — vorher haette der letzte bekannte Wert weiter ausgewertet werden koennen. B. Event-getriebener Tick: - background.py: tick_now()-Funktion + Module-Slot fuer agent_factory damit man von ausserhalb des Lifespan-Pfads einen Tick triggern kann - main.py: POST /triggers/check-now Endpoint ruft tick_now() - bridge: _persist_location feuert nach jedem Save ein fire-and- forget POST /triggers/check-now (run_in_executor, timeout 8s, blockt nichts wenn Brain stockt) Damit fires near() sofort wenn die App ein location_update schickt — Polling ist nur noch der Fallback fuer Watcher OHNE GPS-Bezug (disk_free, hour_of_day etc.) und als Sicherheits-Tick falls location_update mal ausfaellt. Co-Authored-By: Claude Opus 4.7 (1M context) --- aria-brain/background.py | 30 +++++++++++++++++++++++++++++- aria-brain/main.py | 10 ++++++++++ aria-brain/watcher.py | 15 ++++++++++++++- bridge/aria_bridge.py | 29 ++++++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 3 deletions(-) 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."""