jmri_logixng.triggers
Trigger binding — fire a ConditionalNG when watched beans change.
A trigger is a small adapter that:
- Subscribes to the
state_changedSignal of every watched bean. - On any change, evaluates an optional
guardexpression. - If the guard returns true (or is absent), executes the conditional.
Triggers are constructed and bound via AutomationEngine, which
holds a list of triggers and the disconnect callbacks needed to tear
them down cleanly on shutdown.
1"""Trigger binding — fire a :class:`ConditionalNG` when watched beans change. 2 3A trigger is a small adapter that: 4 51. Subscribes to the ``state_changed`` Signal of every watched bean. 62. On any change, evaluates an optional ``guard`` expression. 73. If the guard returns true (or is absent), executes the conditional. 8 9Triggers are constructed and bound via :class:`AutomationEngine`, which 10holds a list of triggers and the disconnect callbacks needed to tear 11them down cleanly on shutdown. 12""" 13from __future__ import annotations 14 15import asyncio 16from collections.abc import Callable 17from dataclasses import dataclass, field 18from typing import Any, Literal 19 20from jmri_logixng.context import ExecutionContext 21from jmri_logixng.logix import ConditionalNG 22from jmri_logixng.tree import Expression 23 24BeanKind = Literal["sensor", "turnout", "light", "memory", "block", "reporter"] 25 26 27@dataclass 28class TriggerBinding: 29 """One trigger — watched beans + optional guard + the conditional to run.""" 30 31 conditional: ConditionalNG 32 watches: list[tuple[BeanKind, str]] # (kind, system_name) 33 guard: Expression[bool] | None = None 34 user_name: str = "" 35 36 37@dataclass 38class AutomationEngine: 39 """Runs a set of triggers against an :class:`ExecutionContext`.""" 40 41 ctx: ExecutionContext 42 triggers: list[TriggerBinding] = field(default_factory=list) 43 _disconnects: list[Callable[[], None]] = field(default_factory=list) 44 45 def add(self, trigger: TriggerBinding) -> None: 46 self.triggers.append(trigger) 47 48 def start(self) -> None: 49 """Wire every trigger's watched beans up to the firing handler.""" 50 for trigger in self.triggers: 51 self._wire(trigger) 52 53 def stop(self) -> None: 54 """Disconnect every listener. Idempotent.""" 55 for disconnect in self._disconnects: 56 disconnect() 57 self._disconnects.clear() 58 59 def _wire(self, trigger: TriggerBinding) -> None: 60 handler = self._make_handler(trigger) 61 for kind, system_name in trigger.watches: 62 bean = self._lookup_bean(kind, system_name) 63 if bean is None: 64 self.ctx.log.warning( 65 "trigger %r: no %s named %r — skipping watch", 66 trigger.user_name or trigger.conditional.id, kind, system_name, 67 ) 68 continue 69 self._disconnects.append(bean.state_changed.connect(handler)) 70 71 def _make_handler(self, trigger: TriggerBinding) -> Callable[[Any], Any]: 72 async def handle(_payload: Any) -> None: 73 if trigger.guard is not None: 74 guard_result = await trigger.guard.evaluate(self.ctx) 75 if not guard_result: 76 self.ctx.emit_trace( 77 f"trigger {trigger.user_name or trigger.conditional.id}: guard false" 78 ) 79 return 80 self.ctx.emit_trace( 81 f"trigger {trigger.user_name or trigger.conditional.id}: firing" 82 ) 83 await trigger.conditional.execute(self.ctx) 84 return handle 85 86 def _lookup_bean(self, kind: BeanKind, system_name: str) -> Any: 87 # Each manager has a ``.get(name)`` method but they're different 88 # generic types; an Any-typed bag keeps the union friendly. 89 managers: dict[str, Any] = { 90 "sensor": self.ctx.connection.sensors, 91 "turnout": self.ctx.connection.turnouts, 92 "light": self.ctx.connection.lights, 93 "memory": self.ctx.connection.memories, 94 "block": self.ctx.connection.blocks, 95 "reporter": self.ctx.connection.reporters, 96 } 97 manager = managers.get(kind) 98 if manager is None: 99 return None 100 return manager.get(system_name) 101 102 103# Touch unused imports so static analyzers don't flag them. 104_ = asyncio
BeanKind =
typing.Literal['sensor', 'turnout', 'light', 'memory', 'block', 'reporter']
@dataclass
class
TriggerBinding:
28@dataclass 29class TriggerBinding: 30 """One trigger — watched beans + optional guard + the conditional to run.""" 31 32 conditional: ConditionalNG 33 watches: list[tuple[BeanKind, str]] # (kind, system_name) 34 guard: Expression[bool] | None = None 35 user_name: str = ""
One trigger — watched beans + optional guard + the conditional to run.
TriggerBinding( conditional: jmri_logixng.ConditionalNG, watches: list[tuple[typing.Literal['sensor', 'turnout', 'light', 'memory', 'block', 'reporter'], str]], guard: Optional[jmri_logixng.Expression[bool]] = None, user_name: str = '')
conditional: jmri_logixng.ConditionalNG
watches: list[tuple[typing.Literal['sensor', 'turnout', 'light', 'memory', 'block', 'reporter'], str]]
@dataclass
class
AutomationEngine:
38@dataclass 39class AutomationEngine: 40 """Runs a set of triggers against an :class:`ExecutionContext`.""" 41 42 ctx: ExecutionContext 43 triggers: list[TriggerBinding] = field(default_factory=list) 44 _disconnects: list[Callable[[], None]] = field(default_factory=list) 45 46 def add(self, trigger: TriggerBinding) -> None: 47 self.triggers.append(trigger) 48 49 def start(self) -> None: 50 """Wire every trigger's watched beans up to the firing handler.""" 51 for trigger in self.triggers: 52 self._wire(trigger) 53 54 def stop(self) -> None: 55 """Disconnect every listener. Idempotent.""" 56 for disconnect in self._disconnects: 57 disconnect() 58 self._disconnects.clear() 59 60 def _wire(self, trigger: TriggerBinding) -> None: 61 handler = self._make_handler(trigger) 62 for kind, system_name in trigger.watches: 63 bean = self._lookup_bean(kind, system_name) 64 if bean is None: 65 self.ctx.log.warning( 66 "trigger %r: no %s named %r — skipping watch", 67 trigger.user_name or trigger.conditional.id, kind, system_name, 68 ) 69 continue 70 self._disconnects.append(bean.state_changed.connect(handler)) 71 72 def _make_handler(self, trigger: TriggerBinding) -> Callable[[Any], Any]: 73 async def handle(_payload: Any) -> None: 74 if trigger.guard is not None: 75 guard_result = await trigger.guard.evaluate(self.ctx) 76 if not guard_result: 77 self.ctx.emit_trace( 78 f"trigger {trigger.user_name or trigger.conditional.id}: guard false" 79 ) 80 return 81 self.ctx.emit_trace( 82 f"trigger {trigger.user_name or trigger.conditional.id}: firing" 83 ) 84 await trigger.conditional.execute(self.ctx) 85 return handle 86 87 def _lookup_bean(self, kind: BeanKind, system_name: str) -> Any: 88 # Each manager has a ``.get(name)`` method but they're different 89 # generic types; an Any-typed bag keeps the union friendly. 90 managers: dict[str, Any] = { 91 "sensor": self.ctx.connection.sensors, 92 "turnout": self.ctx.connection.turnouts, 93 "light": self.ctx.connection.lights, 94 "memory": self.ctx.connection.memories, 95 "block": self.ctx.connection.blocks, 96 "reporter": self.ctx.connection.reporters, 97 } 98 manager = managers.get(kind) 99 if manager is None: 100 return None 101 return manager.get(system_name)
Runs a set of triggers against an ExecutionContext.
AutomationEngine( ctx: jmri_logixng.ExecutionContext, triggers: list[TriggerBinding] = <factory>, _disconnects: list[Callable[[], None]] = <factory>)
triggers: list[TriggerBinding]