jmri_logixng.triggers

Trigger binding — fire a ConditionalNG when watched beans change.

A trigger is a small adapter that:

  1. Subscribes to the state_changed Signal of every watched bean.
  2. On any change, evaluates an optional guard expression.
  3. 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 = '')
watches: list[tuple[typing.Literal['sensor', 'turnout', 'light', 'memory', 'block', 'reporter'], str]]
guard: Optional[jmri_logixng.Expression[bool]] = None
user_name: 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]
def add(self, trigger: TriggerBinding) -> None:
46    def add(self, trigger: TriggerBinding) -> None:
47        self.triggers.append(trigger)
def start(self) -> None:
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)

Wire every trigger's watched beans up to the firing handler.

def stop(self) -> None:
54    def stop(self) -> None:
55        """Disconnect every listener. Idempotent."""
56        for disconnect in self._disconnects:
57            disconnect()
58        self._disconnects.clear()

Disconnect every listener. Idempotent.