fix(trigger): near() fired bei Auto-Vorbeifahrten verpasst — Loop schneller + event-getrieben
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-1
@@ -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))
|
||||
|
||||
+28
-1
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user