feat(trigger): entered_near + left_near — drei Modi fuer near()-Watcher

Stefan: bei aktuellen near()-Watcher gibt's nur "solange drin". Reale
Szenarien wollen aber differenzieren:
- VORWARNUNG vor Ziel (Blitzer-Warner 2 km vorher) → entered_near mit grossem r
- ANKUNFT exakt am Ziel → entered_near mit kleinem r
- VERLASSEN (Parkplatz, hast du was vergessen) → left_near
- KONTINUIERLICH-DRIN (bin noch in der Naehe?) → near (Default, throttled)

Zwei neue Funktionen in der Condition-Whitelist:

- entered_near(lat, lon, r): True NUR im Moment des Uebergangs
  draussen → innen. Fires einmal pro Eintritt.
- left_near(lat, lon, r): True NUR im Moment des Uebergangs innen →
  draussen. Fires einmal pro Austritt.

State-Tracking:
- pro Trigger pro near-Aufruf wird der letzte Auswertungs-Wert (true/
  false) im Watcher-Manifest gespeichert (Field "near_states", Key
  "lat.6,lon.6,radius"). Background-Loop liest's vor dem Eval, gibt's
  per collect_variables(prev_near_states=...) in die Closure, schreibt
  nach dem Eval die neuen Werte zurueck — UNABHAENGIG ob gefeuert
  wurde, sonst greift die Uebergangs-Erkennung nicht.

Background _tick:
- Aufteilung in Watcher-Pass (mit prev_near_states pro Trigger) und
  Timer-Pass (ohne State, gemeinsame vars). Bisher war collect_variables
  einmal pro Tick — jetzt einmal pro Watcher. Disk-Stats sind teuer
  aber unter 30 Watchern unkritisch; bei mehr koennen wir cachen.

ARIA-Tool-Description erweitert (trigger_watcher): erklaert die drei
Modi mit Use-Cases und empfohlenen Throttle-Werten (kurz fuer entered/
left, lang fuer near).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 18:29:39 +02:00
parent 6f80e442cf
commit 435b77e1df
3 changed files with 120 additions and 29 deletions
+39 -18
View File
@@ -164,7 +164,12 @@ async def _fire(trigger: dict, agent_factory) -> None:
async def _tick(agent_factory) -> None:
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist."""
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist.
near()-State-Tracking: entered_near/left_near brauchen die Information
ob ein near()-Aufruf beim letzten Tick true war (Uebergang erkennen).
Wir halten das pro Trigger als near_states-Dict im Manifest und
aktualisieren es nach jedem Eval — auch wenn nicht gefeuert wird."""
try:
all_triggers = triggers_mod.list_triggers(active_only=True)
except Exception as e:
@@ -173,32 +178,48 @@ async def _tick(agent_factory) -> None:
if not all_triggers:
return
now = datetime.now(timezone.utc)
# Variablen einmal pro Tick sammeln (nicht pro Trigger — Disk-Stat ist teuer)
try:
vars_ = watcher_mod.collect_variables()
except Exception as e:
logger.warning("collect_variables: %s", e)
vars_ = {}
# Watcher: last_checked_at jetzt updaten (auch wenn nicht gefeuert wird,
# damit der Check-Interval respektiert wird)
for t in all_triggers:
if t.get("type") == "watcher":
try:
t["last_checked_at"] = _now_iso()
triggers_mod.write(t["name"], t)
except Exception:
pass
for trigger in all_triggers:
if trigger.get("type") != "watcher":
continue
try:
if _should_fire(trigger, vars_, now):
# Variablen pro Trigger sammeln — wegen prev_near_states-Closure
prev = trigger.get("near_states") or {}
vars_ = watcher_mod.collect_variables(prev_near_states=prev)
# Condition evaluieren via _should_fire (intern ruft watcher.evaluate)
fired = _should_fire(trigger, vars_, now)
# State immer updaten, egal ob gefeuert wurde — sonst greift
# entered_near/left_near nicht
new_states = vars_.get("_new_near_states") or {}
trigger["near_states"] = new_states
trigger["last_checked_at"] = _now_iso()
try:
triggers_mod.write(trigger["name"], trigger)
except Exception as e:
logger.warning("trigger.write %s: %s", trigger.get("name"), e)
if fired:
# Feuern als eigener Task — wenn ARIA langsam antwortet,
# darf der naechste Tick nicht blockieren
asyncio.create_task(_fire(trigger, agent_factory))
except Exception as e:
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
# Timer (one-shot) — separat ohne near-State
timer_vars = None
for trigger in all_triggers:
if trigger.get("type") != "timer":
continue
try:
if timer_vars is None:
timer_vars = watcher_mod.collect_variables()
if _should_fire(trigger, timer_vars, now):
asyncio.create_task(_fire(trigger, agent_factory))
except Exception as e:
logger.warning("Timer-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