jmri_logixng

jmri-logixng — Stage 7 of the JMRI Python rewrite.

Tree-and-interpreter implementation of JMRI's LogixNG automation engine. A user writes (or generates) a YAML/JSON file describing the tree; the engine loads it, binds it to the active connection's beans, and fires the appropriate ConditionalNG whenever watched bean state changes.

 1"""jmri-logixng — Stage 7 of the JMRI Python rewrite.
 2
 3Tree-and-interpreter implementation of JMRI's LogixNG automation engine.
 4A user writes (or generates) a YAML/JSON file describing the tree; the
 5engine loads it, binds it to the active connection's beans, and fires
 6the appropriate ConditionalNG whenever watched bean state changes.
 7"""
 8from jmri_logixng.actions import (
 9    Delay,
10    IfThenElse,
11    LogAction,
12    Sequence,
13    SetLightState,
14    SetMemory,
15    SetSensorState,
16    SetTurnoutState,
17)
18from jmri_logixng.context import ExecutionContext
19from jmri_logixng.jmri_importer import (
20    ImportResult,
21    ImportWarning,
22    import_jmri_logixng_xml,
23)
24from jmri_logixng.expressions import (
25    And,
26    ConstantBool,
27    LightIsOn,
28    MemoryEquals,
29    Not,
30    Or,
31    SensorIsActive,
32    TurnoutIsClosed,
33)
34from jmri_logixng.logix import ConditionalNG, LogixNG
35from jmri_logixng.manager import LogixNGManager
36from jmri_logixng.registry import known_tags, lookup, register
37from jmri_logixng.serialize import (
38    from_dict,
39    from_json,
40    from_yaml,
41    load_yaml,
42    save_yaml,
43    to_json,
44    to_yaml,
45)
46from jmri_logixng.tree import Action, Expression, Node
47from jmri_logixng.triggers import AutomationEngine, TriggerBinding
48
49__version__ = "0.1.0"
50
51__all__ = [
52    "Action",
53    "AutomationEngine",
54    "And",
55    "ConditionalNG",
56    "ConstantBool",
57    "Delay",
58    "ExecutionContext",
59    "Expression",
60    "IfThenElse",
61    "ImportResult",
62    "ImportWarning",
63    "LightIsOn",
64    "LogAction",
65    "LogixNG",
66    "LogixNGManager",
67    "MemoryEquals",
68    "Node",
69    "Not",
70    "Or",
71    "SensorIsActive",
72    "Sequence",
73    "SetLightState",
74    "SetMemory",
75    "SetSensorState",
76    "SetTurnoutState",
77    "TriggerBinding",
78    "TurnoutIsClosed",
79    "__version__",
80    "from_dict",
81    "from_json",
82    "from_yaml",
83    "import_jmri_logixng_xml",
84    "known_tags",
85    "load_yaml",
86    "lookup",
87    "register",
88    "save_yaml",
89    "to_json",
90    "to_yaml",
91]
class Action(jmri_logixng.Node):
64class Action(Node):
65    """A node that *does* something when executed."""
66
67    @abstractmethod
68    async def execute(self, ctx: ExecutionContext) -> None: ...

A node that does something when executed.

@abstractmethod
async def execute(self, ctx: ExecutionContext) -> None:
67    @abstractmethod
68    async def execute(self, ctx: ExecutionContext) -> None: ...
@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: 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.

@register
class And(jmri_logixng.Expression[bool]):
134@register
135class And(Expression[bool]):
136    """True iff every child expression evaluates true. Short-circuits."""
137
138    type_tag: ClassVar[str] = "expr:and"
139
140    def __init__(
141        self,
142        children: list[Expression[bool]],
143        *,
144        user_name: str = "",
145        node_id: str | None = None,
146    ) -> None:
147        super().__init__(user_name=user_name, node_id=node_id)
148        self.children = list(children)
149
150    async def evaluate(self, ctx: ExecutionContext) -> bool:
151        for child in self.children:
152            if not await child.evaluate(ctx):
153                return False
154        return True
155
156    def extra_fields(self) -> dict[str, Any]:
157        return {"children": [c.to_dict() for c in self.children]}

True iff every child expression evaluates true. Short-circuits.

And( children: list[Expression[bool]], *, user_name: str = '', node_id: str | None = None)
140    def __init__(
141        self,
142        children: list[Expression[bool]],
143        *,
144        user_name: str = "",
145        node_id: str | None = None,
146    ) -> None:
147        super().__init__(user_name=user_name, node_id=node_id)
148        self.children = list(children)
type_tag: ClassVar[str] = 'expr:and'
children
async def evaluate(self, ctx: ExecutionContext) -> bool:
150    async def evaluate(self, ctx: ExecutionContext) -> bool:
151        for child in self.children:
152            if not await child.evaluate(ctx):
153                return False
154        return True
def extra_fields(self) -> dict[str, typing.Any]:
156    def extra_fields(self) -> dict[str, Any]:
157        return {"children": [c.to_dict() for c in self.children]}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class ConditionalNG(jmri_logixng.Action):
19@register
20class ConditionalNG(Action):
21    """A sequence of root actions executed in order."""
22
23    type_tag: ClassVar[str] = "logixng:conditional"
24
25    def __init__(
26        self,
27        actions: list[Action] | None = None,
28        *,
29        user_name: str = "",
30        node_id: str | None = None,
31        enabled: bool = True,
32    ) -> None:
33        super().__init__(user_name=user_name, node_id=node_id)
34        self.actions: list[Action] = list(actions or [])
35        self.enabled = enabled
36
37    async def execute(self, ctx: ExecutionContext) -> None:
38        if not self.enabled:
39            ctx.emit_trace(f"conditional {self.id!r} skipped (disabled)")
40            return
41        ctx.emit_trace(f"conditional {self.id!r} executing {len(self.actions)} actions")
42        for action in self.actions:
43            await action.execute(ctx)
44
45    def extra_fields(self) -> dict[str, Any]:
46        return {
47            "enabled": self.enabled,
48            "actions": [a.to_dict() for a in self.actions],
49        }

A sequence of root actions executed in order.

ConditionalNG( actions: list[Action] | None = None, *, user_name: str = '', node_id: str | None = None, enabled: bool = True)
25    def __init__(
26        self,
27        actions: list[Action] | None = None,
28        *,
29        user_name: str = "",
30        node_id: str | None = None,
31        enabled: bool = True,
32    ) -> None:
33        super().__init__(user_name=user_name, node_id=node_id)
34        self.actions: list[Action] = list(actions or [])
35        self.enabled = enabled
type_tag: ClassVar[str] = 'logixng:conditional'
actions: list[Action]
enabled
async def execute(self, ctx: ExecutionContext) -> None:
37    async def execute(self, ctx: ExecutionContext) -> None:
38        if not self.enabled:
39            ctx.emit_trace(f"conditional {self.id!r} skipped (disabled)")
40            return
41        ctx.emit_trace(f"conditional {self.id!r} executing {len(self.actions)} actions")
42        for action in self.actions:
43            await action.execute(ctx)
def extra_fields(self) -> dict[str, typing.Any]:
45    def extra_fields(self) -> dict[str, Any]:
46        return {
47            "enabled": self.enabled,
48            "actions": [a.to_dict() for a in self.actions],
49        }

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class ConstantBool(jmri_logixng.Expression[bool]):
14@register
15class ConstantBool(Expression[bool]):
16    """Always evaluates to ``value``. Used for testing + as the leaf of trees."""
17
18    type_tag: ClassVar[str] = "expr:const-bool"
19
20    def __init__(
21        self, *, value: bool, user_name: str = "", node_id: str | None = None
22    ) -> None:
23        super().__init__(user_name=user_name, node_id=node_id)
24        self.value = value
25
26    async def evaluate(self, ctx: ExecutionContext) -> bool:
27        return self.value
28
29    def extra_fields(self) -> dict[str, Any]:
30        return {"value": self.value}

Always evaluates to value. Used for testing + as the leaf of trees.

ConstantBool(*, value: bool, user_name: str = '', node_id: str | None = None)
20    def __init__(
21        self, *, value: bool, user_name: str = "", node_id: str | None = None
22    ) -> None:
23        super().__init__(user_name=user_name, node_id=node_id)
24        self.value = value
type_tag: ClassVar[str] = 'expr:const-bool'
value
async def evaluate(self, ctx: ExecutionContext) -> bool:
26    async def evaluate(self, ctx: ExecutionContext) -> bool:
27        return self.value
def extra_fields(self) -> dict[str, typing.Any]:
29    def extra_fields(self) -> dict[str, Any]:
30        return {"value": self.value}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class Delay(jmri_logixng.Action):
37@register
38class Delay(Action):
39    """Sleep for *seconds*. Useful for timed sequences (signal aspect change → hold)."""
40
41    type_tag: ClassVar[str] = "action:delay"
42
43    def __init__(
44        self,
45        *,
46        seconds: float,
47        user_name: str = "",
48        node_id: str | None = None,
49    ) -> None:
50        super().__init__(user_name=user_name, node_id=node_id)
51        if seconds < 0:
52            raise ValueError(f"delay seconds must be non-negative, got {seconds}")
53        self.seconds = seconds
54
55    async def execute(self, ctx: ExecutionContext) -> None:
56        ctx.emit_trace(f"delay {self.seconds}s")
57        await asyncio.sleep(self.seconds)
58
59    def extra_fields(self) -> dict[str, Any]:
60        return {"seconds": self.seconds}

Sleep for seconds. Useful for timed sequences (signal aspect change → hold).

Delay(*, seconds: float, user_name: str = '', node_id: str | None = None)
43    def __init__(
44        self,
45        *,
46        seconds: float,
47        user_name: str = "",
48        node_id: str | None = None,
49    ) -> None:
50        super().__init__(user_name=user_name, node_id=node_id)
51        if seconds < 0:
52            raise ValueError(f"delay seconds must be non-negative, got {seconds}")
53        self.seconds = seconds
type_tag: ClassVar[str] = 'action:delay'
seconds
async def execute(self, ctx: ExecutionContext) -> None:
55    async def execute(self, ctx: ExecutionContext) -> None:
56        ctx.emit_trace(f"delay {self.seconds}s")
57        await asyncio.sleep(self.seconds)
def extra_fields(self) -> dict[str, typing.Any]:
59    def extra_fields(self) -> dict[str, Any]:
60        return {"seconds": self.seconds}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@dataclass
class ExecutionContext:
20@dataclass
21class ExecutionContext:
22    """Runtime context passed through every node visit."""
23
24    connection: SystemConnection
25    log: logging.Logger = field(default_factory=lambda: logging.getLogger("jmri_logixng"))
26    trace_enabled: bool = False
27    trace: list[str] = field(default_factory=list)
28
29    def emit_trace(self, message: str) -> None:
30        if self.trace_enabled:
31            self.trace.append(message)

Runtime context passed through every node visit.

ExecutionContext( connection: jmri_core.hal.SystemConnection, log: logging.Logger = <factory>, trace_enabled: bool = False, trace: list[str] = <factory>)
log: logging.Logger
trace_enabled: bool = False
trace: list[str]
def emit_trace(self, message: str) -> None:
29    def emit_trace(self, message: str) -> None:
30        if self.trace_enabled:
31            self.trace.append(message)
class Expression(jmri_logixng.Node, typing.Generic[T]):
71class Expression[T](Node):
72    """A node that evaluates to a value of type *T*.
73
74    The most common variant is ``Expression[bool]``. Concrete subclasses
75    implement :meth:`evaluate`.
76    """
77
78    @abstractmethod
79    async def evaluate(self, ctx: ExecutionContext) -> T: ...

A node that evaluates to a value of type T.

The most common variant is Expression[bool]. Concrete subclasses implement evaluate().

@abstractmethod
async def evaluate(self, ctx: ExecutionContext) -> 'T':
78    @abstractmethod
79    async def evaluate(self, ctx: ExecutionContext) -> T: ...
@register
class IfThenElse(jmri_logixng.Action):
145@register
146class IfThenElse(Action):
147    """Evaluate *condition*; run *then_action* if true, *else_action* otherwise."""
148
149    type_tag: ClassVar[str] = "action:if-then-else"
150
151    def __init__(
152        self,
153        *,
154        condition: Expression[bool],
155        then_action: Action,
156        else_action: Action | None = None,
157        user_name: str = "",
158        node_id: str | None = None,
159    ) -> None:
160        super().__init__(user_name=user_name, node_id=node_id)
161        self.condition = condition
162        self.then_action = then_action
163        self.else_action = else_action
164
165    async def execute(self, ctx: ExecutionContext) -> None:
166        result = await self.condition.evaluate(ctx)
167        ctx.emit_trace(f"if-then-else condition={result}")
168        if result:
169            await self.then_action.execute(ctx)
170        elif self.else_action is not None:
171            await self.else_action.execute(ctx)
172
173    def extra_fields(self) -> dict[str, Any]:
174        out: dict[str, Any] = {
175            "condition": self.condition.to_dict(),
176            "then": self.then_action.to_dict(),
177        }
178        if self.else_action is not None:
179            out["else"] = self.else_action.to_dict()
180        return out

Evaluate condition; run then_action if true, else_action otherwise.

IfThenElse( *, condition: Expression[bool], then_action: Action, else_action: Action | None = None, user_name: str = '', node_id: str | None = None)
151    def __init__(
152        self,
153        *,
154        condition: Expression[bool],
155        then_action: Action,
156        else_action: Action | None = None,
157        user_name: str = "",
158        node_id: str | None = None,
159    ) -> None:
160        super().__init__(user_name=user_name, node_id=node_id)
161        self.condition = condition
162        self.then_action = then_action
163        self.else_action = else_action
type_tag: ClassVar[str] = 'action:if-then-else'
condition
then_action
else_action
async def execute(self, ctx: ExecutionContext) -> None:
165    async def execute(self, ctx: ExecutionContext) -> None:
166        result = await self.condition.evaluate(ctx)
167        ctx.emit_trace(f"if-then-else condition={result}")
168        if result:
169            await self.then_action.execute(ctx)
170        elif self.else_action is not None:
171            await self.else_action.execute(ctx)
def extra_fields(self) -> dict[str, typing.Any]:
173    def extra_fields(self) -> dict[str, Any]:
174        out: dict[str, Any] = {
175            "condition": self.condition.to_dict(),
176            "then": self.then_action.to_dict(),
177        }
178        if self.else_action is not None:
179            out["else"] = self.else_action.to_dict()
180        return out

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@dataclass(slots=True)
class ImportResult:
103@dataclass(slots=True)
104class ImportResult:
105    """Outcome of :func:`import_jmri_logixng_xml`."""
106
107    programs: list[LogixNG] = field(default_factory=list)
108    written: list[pathlib.Path] = field(default_factory=list)
109    warnings: list[ImportWarning] = field(default_factory=list)
ImportResult( programs: list[LogixNG] = <factory>, written: list[pathlib._local.Path] = <factory>, warnings: list[ImportWarning] = <factory>)
programs: list[LogixNG]
written: list[pathlib._local.Path]
warnings: list[ImportWarning]
@dataclass(frozen=True, slots=True)
class ImportWarning:
 94@dataclass(frozen=True, slots=True)
 95class ImportWarning:
 96    """One diagnostic the importer recorded during a run."""
 97
 98    kind: str          # e.g. "unknown-tag", "non-direct-addressing"
 99    detail: str        # human-readable specifics
100    system_name: str   # the JMRI system name that produced it

One diagnostic the importer recorded during a run.

ImportWarning(kind: str, detail: str, system_name: str)
kind: str
detail: str
system_name: str
@register
class LightIsOn(jmri_logixng.Expression[bool]):
79@register
80class LightIsOn(Expression[bool]):
81    """True when light *light* has state ON."""
82
83    type_tag: ClassVar[str] = "expr:light-on"
84
85    def __init__(
86        self, *, light: str, user_name: str = "", node_id: str | None = None
87    ) -> None:
88        super().__init__(user_name=user_name, node_id=node_id)
89        self.light = light
90
91    async def evaluate(self, ctx: ExecutionContext) -> bool:
92        bean = ctx.connection.lights.get(self.light)
93        if bean is None:
94            ctx.log.warning("LightIsOn: no light %r", self.light)
95            return False
96        return bean.state == LightState.ON
97
98    def extra_fields(self) -> dict[str, Any]:
99        return {"light": self.light}

True when light light has state ON.

LightIsOn(*, light: str, user_name: str = '', node_id: str | None = None)
85    def __init__(
86        self, *, light: str, user_name: str = "", node_id: str | None = None
87    ) -> None:
88        super().__init__(user_name=user_name, node_id=node_id)
89        self.light = light
type_tag: ClassVar[str] = 'expr:light-on'
light
async def evaluate(self, ctx: ExecutionContext) -> bool:
91    async def evaluate(self, ctx: ExecutionContext) -> bool:
92        bean = ctx.connection.lights.get(self.light)
93        if bean is None:
94            ctx.log.warning("LightIsOn: no light %r", self.light)
95            return False
96        return bean.state == LightState.ON
def extra_fields(self) -> dict[str, typing.Any]:
98    def extra_fields(self) -> dict[str, Any]:
99        return {"light": self.light}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class LogAction(jmri_logixng.Action):
183@register
184class LogAction(Action):
185    """Emit a log message. Mostly useful for debugging automations."""
186
187    type_tag: ClassVar[str] = "action:log"
188
189    def __init__(
190        self,
191        *,
192        message: str,
193        level: str = "info",
194        user_name: str = "",
195        node_id: str | None = None,
196    ) -> None:
197        super().__init__(user_name=user_name, node_id=node_id)
198        self.message = message
199        self.level = level
200
201    async def execute(self, ctx: ExecutionContext) -> None:
202        ctx.emit_trace(f"log[{self.level}]: {self.message}")
203        getattr(ctx.log, self.level, ctx.log.info)(self.message)
204
205    def extra_fields(self) -> dict[str, Any]:
206        return {"message": self.message, "level": self.level}

Emit a log message. Mostly useful for debugging automations.

LogAction( *, message: str, level: str = 'info', user_name: str = '', node_id: str | None = None)
189    def __init__(
190        self,
191        *,
192        message: str,
193        level: str = "info",
194        user_name: str = "",
195        node_id: str | None = None,
196    ) -> None:
197        super().__init__(user_name=user_name, node_id=node_id)
198        self.message = message
199        self.level = level
type_tag: ClassVar[str] = 'action:log'
message
level
async def execute(self, ctx: ExecutionContext) -> None:
201    async def execute(self, ctx: ExecutionContext) -> None:
202        ctx.emit_trace(f"log[{self.level}]: {self.message}")
203        getattr(ctx.log, self.level, ctx.log.info)(self.message)
def extra_fields(self) -> dict[str, typing.Any]:
205    def extra_fields(self) -> dict[str, Any]:
206        return {"message": self.message, "level": self.level}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class LogixNG(jmri_logixng.Node):
52@register
53class LogixNG(Node):
54    """A named collection of :class:`ConditionalNG` instances."""
55
56    type_tag: ClassVar[str] = "logixng:root"
57
58    def __init__(
59        self,
60        conditionals: list[ConditionalNG] | None = None,
61        *,
62        user_name: str = "",
63        node_id: str | None = None,
64        enabled: bool = True,
65    ) -> None:
66        super().__init__(user_name=user_name, node_id=node_id)
67        self.conditionals: list[ConditionalNG] = list(conditionals or [])
68        self.enabled = enabled
69
70    def add(self, conditional: ConditionalNG) -> None:
71        self.conditionals.append(conditional)
72
73    async def execute_all(self, ctx: ExecutionContext) -> None:
74        """Execute every conditional in order (for fire-and-forget runs)."""
75        if not self.enabled:
76            return
77        for c in self.conditionals:
78            await c.execute(ctx)
79
80    def extra_fields(self) -> dict[str, Any]:
81        return {
82            "enabled": self.enabled,
83            "conditionals": [c.to_dict() for c in self.conditionals],
84        }

A named collection of ConditionalNG instances.

LogixNG( conditionals: list[ConditionalNG] | None = None, *, user_name: str = '', node_id: str | None = None, enabled: bool = True)
58    def __init__(
59        self,
60        conditionals: list[ConditionalNG] | None = None,
61        *,
62        user_name: str = "",
63        node_id: str | None = None,
64        enabled: bool = True,
65    ) -> None:
66        super().__init__(user_name=user_name, node_id=node_id)
67        self.conditionals: list[ConditionalNG] = list(conditionals or [])
68        self.enabled = enabled
type_tag: ClassVar[str] = 'logixng:root'
conditionals: list[ConditionalNG]
enabled
def add(self, conditional: ConditionalNG) -> None:
70    def add(self, conditional: ConditionalNG) -> None:
71        self.conditionals.append(conditional)
async def execute_all(self, ctx: ExecutionContext) -> None:
73    async def execute_all(self, ctx: ExecutionContext) -> None:
74        """Execute every conditional in order (for fire-and-forget runs)."""
75        if not self.enabled:
76            return
77        for c in self.conditionals:
78            await c.execute(ctx)

Execute every conditional in order (for fire-and-forget runs).

def extra_fields(self) -> dict[str, typing.Any]:
80    def extra_fields(self) -> dict[str, Any]:
81        return {
82            "enabled": self.enabled,
83            "conditionals": [c.to_dict() for c in self.conditionals],
84        }

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
class LogixNGManager:
 51class LogixNGManager:
 52    """Registry of :class:`LogixNG` programs, one per profile.
 53
 54    Not a :class:`~jmri_core.manager.Manager` — LogixNG isn't a
 55    NamedBean and the system-name shape is program-oriented rather
 56    than address-oriented. The signal / index shape mirrors Manager
 57    closely so callers can subscribe in the same pattern.
 58    """
 59
 60    def __init__(self, prefix: str = _DEFAULT_PREFIX) -> None:
 61        self.prefix = prefix
 62        self._by_name: dict[str, LogixNG] = {}
 63        self.added: Signal[LogixNG] = Signal()
 64        self.removed: Signal[LogixNG] = Signal()
 65        self.updated: Signal[LogixNG] = Signal()
 66
 67    # ---- read ------------------------------------------------------------
 68
 69    def __iter__(self) -> Iterator[LogixNG]:
 70        return iter(self._by_name.values())
 71
 72    def __len__(self) -> int:
 73        return len(self._by_name)
 74
 75    def __contains__(self, name: str) -> bool:
 76        return name in self._by_name
 77
 78    def get(self, system_name: str) -> LogixNG | None:
 79        return self._by_name.get(system_name)
 80
 81    def names(self) -> list[str]:
 82        return list(self._by_name)
 83
 84    # ---- mutate ---------------------------------------------------------
 85
 86    async def put(self, system_name: str, logixng: LogixNG) -> LogixNG:
 87        """Insert or replace *logixng* under *system_name*. Fires
 88        ``added`` on a new entry and ``updated`` on a replace."""
 89        if not _SAFE_NAME_RE.match(system_name):
 90            raise ValueError(
 91                f"logixng system name {system_name!r} must match "
 92                f"[A-Za-z0-9_.:-]+"
 93            )
 94        existing = self._by_name.get(system_name)
 95        self._by_name[system_name] = logixng
 96        if existing is None:
 97            await self.added.emit(logixng)
 98        else:
 99            await self.updated.emit(logixng)
100        return logixng
101
102    async def create(
103        self, system_name: str, *, user_name: str = "",
104        enabled: bool = True,
105    ) -> LogixNG:
106        """Create an empty LogixNG program at *system_name*. Idempotent
107        by name — returns the existing program if one already lives
108        there (matches how the base bean managers behave)."""
109        if system_name in self._by_name:
110            return self._by_name[system_name]
111        prog = LogixNG(user_name=user_name, enabled=enabled)
112        return await self.put(system_name, prog)
113
114    async def delete(self, system_name: str) -> bool:
115        """Remove the program at *system_name*. Returns True if one
116        was found. Fires ``removed``."""
117        prog = self._by_name.pop(system_name, None)
118        if prog is None:
119            return False
120        await self.removed.emit(prog)
121        return True
122
123    # ---- disk ----------------------------------------------------------
124
125    def save_all(self, root: pathlib.Path) -> None:
126        """Write every program to ``root/<system_name>.yaml``.
127
128        Creates ``root`` if absent. Overwrites any existing files;
129        stale files (programs deleted since the last save) are
130        removed so the on-disk set matches memory.
131        """
132        root.mkdir(parents=True, exist_ok=True)
133        keep: set[pathlib.Path] = set()
134        for system_name, prog in self._by_name.items():
135            path = root / f"{system_name}.yaml"
136            save_yaml(prog, path)
137            keep.add(path)
138        for existing in root.glob("*.yaml"):
139            if existing not in keep:
140                try:
141                    existing.unlink()
142                except OSError as e:
143                    _log.warning(
144                        "logixng: couldn't remove stale %s (%s)", existing, e,
145                    )
146
147    def save_one(self, root: pathlib.Path, system_name: str) -> None:
148        """Persist a single program without touching siblings. Cheaper
149        than :meth:`save_all` when only one entry changed."""
150        prog = self._by_name.get(system_name)
151        if prog is None:
152            return
153        root.mkdir(parents=True, exist_ok=True)
154        save_yaml(prog, root / f"{system_name}.yaml")
155
156    def delete_on_disk(self, root: pathlib.Path, system_name: str) -> None:
157        """Remove ``<root>/<system_name>.yaml`` if it exists. Used by
158        the service's ``delete`` handler so the file goes away when the
159        in-memory entry does."""
160        path = root / f"{system_name}.yaml"
161        if path.exists():
162            try:
163                path.unlink()
164            except OSError as e:
165                _log.warning(
166                    "logixng: couldn't unlink %s (%s)", path, e,
167                )
168
169    async def load_all(self, root: pathlib.Path) -> int:
170        """Populate the manager from ``root/*.yaml``.
171
172        Returns the number of programs loaded. Files that fail to
173        parse are logged and skipped — one broken file doesn't
174        prevent the rest from loading.
175        """
176        if not root.is_dir():
177            return 0
178        count = 0
179        for yaml_path in sorted(root.glob("*.yaml")):
180            system_name = yaml_path.stem
181            if not _SAFE_NAME_RE.match(system_name):
182                _log.warning(
183                    "logixng: skipping %s (unsafe filename)", yaml_path,
184                )
185                continue
186            try:
187                node = load_yaml(yaml_path)
188            except Exception as e:  # noqa: BLE001 — one bad file mustn't block the rest
189                # YAML parse errors, decoder failures, IO errors — all
190                # get logged and skipped. The manager keeps loading
191                # siblings so one broken file doesn't nuke the whole
192                # set.
193                _log.warning(
194                    "logixng: skipping %s (%s)", yaml_path, e,
195                )
196                continue
197            if not isinstance(node, LogixNG):
198                _log.warning(
199                    "logixng: %s isn't a LogixNG root, skipping", yaml_path,
200                )
201                continue
202            await self.put(system_name, node)
203            count += 1
204        return count
205
206    async def load_yaml_text(
207        self, system_name: str, text: str,
208    ) -> LogixNG:
209        """Parse *text* as a LogixNG YAML program and register it at
210        *system_name*. Convenient for JSON-service uploads that ship
211        the tree inline."""
212        node = from_yaml(text)
213        if not isinstance(node, LogixNG):
214            raise ValueError("payload is not a LogixNG root")
215        return await self.put(system_name, node)

Registry of LogixNG programs, one per profile.

Not a ~jmri_core.manager.Manager — LogixNG isn't a NamedBean and the system-name shape is program-oriented rather than address-oriented. The signal / index shape mirrors Manager closely so callers can subscribe in the same pattern.

LogixNGManager(prefix: str = 'IQ')
60    def __init__(self, prefix: str = _DEFAULT_PREFIX) -> None:
61        self.prefix = prefix
62        self._by_name: dict[str, LogixNG] = {}
63        self.added: Signal[LogixNG] = Signal()
64        self.removed: Signal[LogixNG] = Signal()
65        self.updated: Signal[LogixNG] = Signal()
prefix
def get(self, system_name: str) -> LogixNG | None:
78    def get(self, system_name: str) -> LogixNG | None:
79        return self._by_name.get(system_name)
def names(self) -> list[str]:
81    def names(self) -> list[str]:
82        return list(self._by_name)
async def put( self, system_name: str, logixng: LogixNG) -> LogixNG:
 86    async def put(self, system_name: str, logixng: LogixNG) -> LogixNG:
 87        """Insert or replace *logixng* under *system_name*. Fires
 88        ``added`` on a new entry and ``updated`` on a replace."""
 89        if not _SAFE_NAME_RE.match(system_name):
 90            raise ValueError(
 91                f"logixng system name {system_name!r} must match "
 92                f"[A-Za-z0-9_.:-]+"
 93            )
 94        existing = self._by_name.get(system_name)
 95        self._by_name[system_name] = logixng
 96        if existing is None:
 97            await self.added.emit(logixng)
 98        else:
 99            await self.updated.emit(logixng)
100        return logixng

Insert or replace logixng under system_name. Fires added on a new entry and updated on a replace.

async def create( self, system_name: str, *, user_name: str = '', enabled: bool = True) -> LogixNG:
102    async def create(
103        self, system_name: str, *, user_name: str = "",
104        enabled: bool = True,
105    ) -> LogixNG:
106        """Create an empty LogixNG program at *system_name*. Idempotent
107        by name — returns the existing program if one already lives
108        there (matches how the base bean managers behave)."""
109        if system_name in self._by_name:
110            return self._by_name[system_name]
111        prog = LogixNG(user_name=user_name, enabled=enabled)
112        return await self.put(system_name, prog)

Create an empty LogixNG program at system_name. Idempotent by name — returns the existing program if one already lives there (matches how the base bean managers behave).

async def delete(self, system_name: str) -> bool:
114    async def delete(self, system_name: str) -> bool:
115        """Remove the program at *system_name*. Returns True if one
116        was found. Fires ``removed``."""
117        prog = self._by_name.pop(system_name, None)
118        if prog is None:
119            return False
120        await self.removed.emit(prog)
121        return True

Remove the program at system_name. Returns True if one was found. Fires removed.

def save_all(self, root: pathlib._local.Path) -> None:
125    def save_all(self, root: pathlib.Path) -> None:
126        """Write every program to ``root/<system_name>.yaml``.
127
128        Creates ``root`` if absent. Overwrites any existing files;
129        stale files (programs deleted since the last save) are
130        removed so the on-disk set matches memory.
131        """
132        root.mkdir(parents=True, exist_ok=True)
133        keep: set[pathlib.Path] = set()
134        for system_name, prog in self._by_name.items():
135            path = root / f"{system_name}.yaml"
136            save_yaml(prog, path)
137            keep.add(path)
138        for existing in root.glob("*.yaml"):
139            if existing not in keep:
140                try:
141                    existing.unlink()
142                except OSError as e:
143                    _log.warning(
144                        "logixng: couldn't remove stale %s (%s)", existing, e,
145                    )

Write every program to root/<system_name>.yaml.

Creates root if absent. Overwrites any existing files; stale files (programs deleted since the last save) are removed so the on-disk set matches memory.

def save_one(self, root: pathlib._local.Path, system_name: str) -> None:
147    def save_one(self, root: pathlib.Path, system_name: str) -> None:
148        """Persist a single program without touching siblings. Cheaper
149        than :meth:`save_all` when only one entry changed."""
150        prog = self._by_name.get(system_name)
151        if prog is None:
152            return
153        root.mkdir(parents=True, exist_ok=True)
154        save_yaml(prog, root / f"{system_name}.yaml")

Persist a single program without touching siblings. Cheaper than save_all() when only one entry changed.

def delete_on_disk(self, root: pathlib._local.Path, system_name: str) -> None:
156    def delete_on_disk(self, root: pathlib.Path, system_name: str) -> None:
157        """Remove ``<root>/<system_name>.yaml`` if it exists. Used by
158        the service's ``delete`` handler so the file goes away when the
159        in-memory entry does."""
160        path = root / f"{system_name}.yaml"
161        if path.exists():
162            try:
163                path.unlink()
164            except OSError as e:
165                _log.warning(
166                    "logixng: couldn't unlink %s (%s)", path, e,
167                )

Remove <root>/<system_name>.yaml if it exists. Used by the service's delete handler so the file goes away when the in-memory entry does.

async def load_all(self, root: pathlib._local.Path) -> int:
169    async def load_all(self, root: pathlib.Path) -> int:
170        """Populate the manager from ``root/*.yaml``.
171
172        Returns the number of programs loaded. Files that fail to
173        parse are logged and skipped — one broken file doesn't
174        prevent the rest from loading.
175        """
176        if not root.is_dir():
177            return 0
178        count = 0
179        for yaml_path in sorted(root.glob("*.yaml")):
180            system_name = yaml_path.stem
181            if not _SAFE_NAME_RE.match(system_name):
182                _log.warning(
183                    "logixng: skipping %s (unsafe filename)", yaml_path,
184                )
185                continue
186            try:
187                node = load_yaml(yaml_path)
188            except Exception as e:  # noqa: BLE001 — one bad file mustn't block the rest
189                # YAML parse errors, decoder failures, IO errors — all
190                # get logged and skipped. The manager keeps loading
191                # siblings so one broken file doesn't nuke the whole
192                # set.
193                _log.warning(
194                    "logixng: skipping %s (%s)", yaml_path, e,
195                )
196                continue
197            if not isinstance(node, LogixNG):
198                _log.warning(
199                    "logixng: %s isn't a LogixNG root, skipping", yaml_path,
200                )
201                continue
202            await self.put(system_name, node)
203            count += 1
204        return count

Populate the manager from root/*.yaml.

Returns the number of programs loaded. Files that fail to parse are logged and skipped — one broken file doesn't prevent the rest from loading.

async def load_yaml_text(self, system_name: str, text: str) -> LogixNG:
206    async def load_yaml_text(
207        self, system_name: str, text: str,
208    ) -> LogixNG:
209        """Parse *text* as a LogixNG YAML program and register it at
210        *system_name*. Convenient for JSON-service uploads that ship
211        the tree inline."""
212        node = from_yaml(text)
213        if not isinstance(node, LogixNG):
214            raise ValueError("payload is not a LogixNG root")
215        return await self.put(system_name, node)

Parse text as a LogixNG YAML program and register it at system_name. Convenient for JSON-service uploads that ship the tree inline.

@register
class MemoryEquals(jmri_logixng.Expression[bool]):
102@register
103class MemoryEquals(Expression[bool]):
104    """True when memory *memory*'s value compares equal to *value*."""
105
106    type_tag: ClassVar[str] = "expr:memory-equals"
107
108    def __init__(
109        self,
110        *,
111        memory: str,
112        value: Any,
113        user_name: str = "",
114        node_id: str | None = None,
115    ) -> None:
116        super().__init__(user_name=user_name, node_id=node_id)
117        self.memory = memory
118        self.value = value
119
120    async def evaluate(self, ctx: ExecutionContext) -> bool:
121        bean = ctx.connection.memories.get(self.memory)
122        if bean is None:
123            ctx.log.warning("MemoryEquals: no memory %r", self.memory)
124            return False
125        return bool(bean.value == self.value)
126
127    def extra_fields(self) -> dict[str, Any]:
128        return {"memory": self.memory, "value": self.value}

True when memory memory's value compares equal to value.

MemoryEquals( *, memory: str, value: Any, user_name: str = '', node_id: str | None = None)
108    def __init__(
109        self,
110        *,
111        memory: str,
112        value: Any,
113        user_name: str = "",
114        node_id: str | None = None,
115    ) -> None:
116        super().__init__(user_name=user_name, node_id=node_id)
117        self.memory = memory
118        self.value = value
type_tag: ClassVar[str] = 'expr:memory-equals'
memory
value
async def evaluate(self, ctx: ExecutionContext) -> bool:
120    async def evaluate(self, ctx: ExecutionContext) -> bool:
121        bean = ctx.connection.memories.get(self.memory)
122        if bean is None:
123            ctx.log.warning("MemoryEquals: no memory %r", self.memory)
124            return False
125        return bool(bean.value == self.value)
def extra_fields(self) -> dict[str, typing.Any]:
127    def extra_fields(self) -> dict[str, Any]:
128        return {"memory": self.memory, "value": self.value}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
class Node(abc.ABC):
31class Node(ABC):  # noqa: B024 — abstract by intent; the type_tag rule is enforced
32    """Common base for every LogixNG element."""
33
34    type_tag: ClassVar[str] = ""   # subclasses must set
35
36    def __init__(self, *, user_name: str = "", node_id: str | None = None) -> None:
37        if not self.type_tag:
38            raise TypeError(f"{type(self).__name__}.type_tag must be set on the class")
39        self.user_name = user_name
40        self.id = node_id or f"{type(self).__name__}-{uuid.uuid4().hex[:8]}"
41
42    def to_dict(self) -> dict[str, Any]:
43        """Serialize this node + its children to a dict suitable for YAML/JSON.
44
45        Default implementation captures ``type``, ``id``, ``user_name``.
46        Subclasses extend by overriding :meth:`extra_fields`.
47        """
48        out: dict[str, Any] = {"type": self.type_tag, "id": self.id}
49        if self.user_name:
50            out["user_name"] = self.user_name
51        out.update(self.extra_fields())
52        return out
53
54    def extra_fields(self) -> dict[str, Any]:
55        """Hook for subclasses to add their own fields to ``to_dict``."""
56        return {}
57
58    def __repr__(self) -> str:
59        if self.user_name:
60            return f"<{type(self).__name__} {self.user_name!r}>"
61        return f"<{type(self).__name__} {self.id}>"

Common base for every LogixNG element.

Node(*, user_name: str = '', node_id: str | None = None)
36    def __init__(self, *, user_name: str = "", node_id: str | None = None) -> None:
37        if not self.type_tag:
38            raise TypeError(f"{type(self).__name__}.type_tag must be set on the class")
39        self.user_name = user_name
40        self.id = node_id or f"{type(self).__name__}-{uuid.uuid4().hex[:8]}"
type_tag: ClassVar[str] = ''
user_name
id
def to_dict(self) -> dict[str, typing.Any]:
42    def to_dict(self) -> dict[str, Any]:
43        """Serialize this node + its children to a dict suitable for YAML/JSON.
44
45        Default implementation captures ``type``, ``id``, ``user_name``.
46        Subclasses extend by overriding :meth:`extra_fields`.
47        """
48        out: dict[str, Any] = {"type": self.type_tag, "id": self.id}
49        if self.user_name:
50            out["user_name"] = self.user_name
51        out.update(self.extra_fields())
52        return out

Serialize this node + its children to a dict suitable for YAML/JSON.

Default implementation captures type, id, user_name. Subclasses extend by overriding extra_fields().

def extra_fields(self) -> dict[str, typing.Any]:
54    def extra_fields(self) -> dict[str, Any]:
55        """Hook for subclasses to add their own fields to ``to_dict``."""
56        return {}

Hook for subclasses to add their own fields to to_dict.

@register
class Not(jmri_logixng.Expression[bool]):
186@register
187class Not(Expression[bool]):
188    """Negates its child expression."""
189
190    type_tag: ClassVar[str] = "expr:not"
191
192    def __init__(
193        self,
194        child: Expression[bool],
195        *,
196        user_name: str = "",
197        node_id: str | None = None,
198    ) -> None:
199        super().__init__(user_name=user_name, node_id=node_id)
200        self.child = child
201
202    async def evaluate(self, ctx: ExecutionContext) -> bool:
203        return not await self.child.evaluate(ctx)
204
205    def extra_fields(self) -> dict[str, Any]:
206        return {"child": self.child.to_dict()}

Negates its child expression.

Not( child: Expression[bool], *, user_name: str = '', node_id: str | None = None)
192    def __init__(
193        self,
194        child: Expression[bool],
195        *,
196        user_name: str = "",
197        node_id: str | None = None,
198    ) -> None:
199        super().__init__(user_name=user_name, node_id=node_id)
200        self.child = child
type_tag: ClassVar[str] = 'expr:not'
child
async def evaluate(self, ctx: ExecutionContext) -> bool:
202    async def evaluate(self, ctx: ExecutionContext) -> bool:
203        return not await self.child.evaluate(ctx)
def extra_fields(self) -> dict[str, typing.Any]:
205    def extra_fields(self) -> dict[str, Any]:
206        return {"child": self.child.to_dict()}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class Or(jmri_logixng.Expression[bool]):
160@register
161class Or(Expression[bool]):
162    """True if any child expression evaluates true. Short-circuits."""
163
164    type_tag: ClassVar[str] = "expr:or"
165
166    def __init__(
167        self,
168        children: list[Expression[bool]],
169        *,
170        user_name: str = "",
171        node_id: str | None = None,
172    ) -> None:
173        super().__init__(user_name=user_name, node_id=node_id)
174        self.children = list(children)
175
176    async def evaluate(self, ctx: ExecutionContext) -> bool:
177        for child in self.children:
178            if await child.evaluate(ctx):
179                return True
180        return False
181
182    def extra_fields(self) -> dict[str, Any]:
183        return {"children": [c.to_dict() for c in self.children]}

True if any child expression evaluates true. Short-circuits.

Or( children: list[Expression[bool]], *, user_name: str = '', node_id: str | None = None)
166    def __init__(
167        self,
168        children: list[Expression[bool]],
169        *,
170        user_name: str = "",
171        node_id: str | None = None,
172    ) -> None:
173        super().__init__(user_name=user_name, node_id=node_id)
174        self.children = list(children)
type_tag: ClassVar[str] = 'expr:or'
children
async def evaluate(self, ctx: ExecutionContext) -> bool:
176    async def evaluate(self, ctx: ExecutionContext) -> bool:
177        for child in self.children:
178            if await child.evaluate(ctx):
179                return True
180        return False
def extra_fields(self) -> dict[str, typing.Any]:
182    def extra_fields(self) -> dict[str, Any]:
183        return {"children": [c.to_dict() for c in self.children]}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class SensorIsActive(jmri_logixng.Expression[bool]):
33@register
34class SensorIsActive(Expression[bool]):
35    """True when sensor *sensor* has state ACTIVE."""
36
37    type_tag: ClassVar[str] = "expr:sensor-active"
38
39    def __init__(
40        self, *, sensor: str, user_name: str = "", node_id: str | None = None
41    ) -> None:
42        super().__init__(user_name=user_name, node_id=node_id)
43        self.sensor = sensor
44
45    async def evaluate(self, ctx: ExecutionContext) -> bool:
46        bean = ctx.connection.sensors.get(self.sensor)
47        if bean is None:
48            ctx.log.warning("SensorIsActive: no sensor %r", self.sensor)
49            return False
50        return bean.known_state == SensorState.ACTIVE
51
52    def extra_fields(self) -> dict[str, Any]:
53        return {"sensor": self.sensor}

True when sensor sensor has state ACTIVE.

SensorIsActive(*, sensor: str, user_name: str = '', node_id: str | None = None)
39    def __init__(
40        self, *, sensor: str, user_name: str = "", node_id: str | None = None
41    ) -> None:
42        super().__init__(user_name=user_name, node_id=node_id)
43        self.sensor = sensor
type_tag: ClassVar[str] = 'expr:sensor-active'
sensor
async def evaluate(self, ctx: ExecutionContext) -> bool:
45    async def evaluate(self, ctx: ExecutionContext) -> bool:
46        bean = ctx.connection.sensors.get(self.sensor)
47        if bean is None:
48            ctx.log.warning("SensorIsActive: no sensor %r", self.sensor)
49            return False
50        return bean.known_state == SensorState.ACTIVE
def extra_fields(self) -> dict[str, typing.Any]:
52    def extra_fields(self) -> dict[str, Any]:
53        return {"sensor": self.sensor}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class Sequence(jmri_logixng.Action):
13@register
14class Sequence(Action):
15    """Run a list of actions in order."""
16
17    type_tag: ClassVar[str] = "action:sequence"
18
19    def __init__(
20        self,
21        actions: list[Action],
22        *,
23        user_name: str = "",
24        node_id: str | None = None,
25    ) -> None:
26        super().__init__(user_name=user_name, node_id=node_id)
27        self.actions = list(actions)
28
29    async def execute(self, ctx: ExecutionContext) -> None:
30        for action in self.actions:
31            await action.execute(ctx)
32
33    def extra_fields(self) -> dict[str, Any]:
34        return {"actions": [a.to_dict() for a in self.actions]}

Run a list of actions in order.

Sequence( actions: list[Action], *, user_name: str = '', node_id: str | None = None)
19    def __init__(
20        self,
21        actions: list[Action],
22        *,
23        user_name: str = "",
24        node_id: str | None = None,
25    ) -> None:
26        super().__init__(user_name=user_name, node_id=node_id)
27        self.actions = list(actions)
type_tag: ClassVar[str] = 'action:sequence'
actions
async def execute(self, ctx: ExecutionContext) -> None:
29    async def execute(self, ctx: ExecutionContext) -> None:
30        for action in self.actions:
31            await action.execute(ctx)
def extra_fields(self) -> dict[str, typing.Any]:
33    def extra_fields(self) -> dict[str, Any]:
34        return {"actions": [a.to_dict() for a in self.actions]}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class SetLightState(jmri_logixng.Action):
 85@register
 86class SetLightState(Action):
 87    """Set a light's state."""
 88
 89    type_tag: ClassVar[str] = "action:set-light"
 90
 91    def __init__(
 92        self,
 93        *,
 94        light: str,
 95        state: LightState,
 96        user_name: str = "",
 97        node_id: str | None = None,
 98    ) -> None:
 99        super().__init__(user_name=user_name, node_id=node_id)
100        self.light = light
101        self.state = state
102
103    async def execute(self, ctx: ExecutionContext) -> None:
104        bean = ctx.connection.lights.get(self.light)
105        if bean is None:
106            ctx.log.warning("SetLightState: no light %r", self.light)
107            return
108        ctx.emit_trace(f"set light {self.light} → {self.state.name}")
109        await bean.set_state(self.state)
110
111    def extra_fields(self) -> dict[str, Any]:
112        return {"light": self.light, "state": self.state.name}

Set a light's state.

SetLightState( *, light: str, state: jmri_core.state.LightState, user_name: str = '', node_id: str | None = None)
 91    def __init__(
 92        self,
 93        *,
 94        light: str,
 95        state: LightState,
 96        user_name: str = "",
 97        node_id: str | None = None,
 98    ) -> None:
 99        super().__init__(user_name=user_name, node_id=node_id)
100        self.light = light
101        self.state = state
type_tag: ClassVar[str] = 'action:set-light'
light
state
async def execute(self, ctx: ExecutionContext) -> None:
103    async def execute(self, ctx: ExecutionContext) -> None:
104        bean = ctx.connection.lights.get(self.light)
105        if bean is None:
106            ctx.log.warning("SetLightState: no light %r", self.light)
107            return
108        ctx.emit_trace(f"set light {self.light} → {self.state.name}")
109        await bean.set_state(self.state)
def extra_fields(self) -> dict[str, typing.Any]:
111    def extra_fields(self) -> dict[str, Any]:
112        return {"light": self.light, "state": self.state.name}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class SetMemory(jmri_logixng.Action):
115@register
116class SetMemory(Action):
117    """Write a value into a memory bean."""
118
119    type_tag: ClassVar[str] = "action:set-memory"
120
121    def __init__(
122        self,
123        *,
124        memory: str,
125        value: Any,
126        user_name: str = "",
127        node_id: str | None = None,
128    ) -> None:
129        super().__init__(user_name=user_name, node_id=node_id)
130        self.memory = memory
131        self.value = value
132
133    async def execute(self, ctx: ExecutionContext) -> None:
134        bean = ctx.connection.memories.get(self.memory)
135        if bean is None:
136            ctx.log.warning("SetMemory: no memory %r", self.memory)
137            return
138        ctx.emit_trace(f"set memory {self.memory} → {self.value!r}")
139        await bean.set_value(self.value)
140
141    def extra_fields(self) -> dict[str, Any]:
142        return {"memory": self.memory, "value": self.value}

Write a value into a memory bean.

SetMemory( *, memory: str, value: Any, user_name: str = '', node_id: str | None = None)
121    def __init__(
122        self,
123        *,
124        memory: str,
125        value: Any,
126        user_name: str = "",
127        node_id: str | None = None,
128    ) -> None:
129        super().__init__(user_name=user_name, node_id=node_id)
130        self.memory = memory
131        self.value = value
type_tag: ClassVar[str] = 'action:set-memory'
memory
value
async def execute(self, ctx: ExecutionContext) -> None:
133    async def execute(self, ctx: ExecutionContext) -> None:
134        bean = ctx.connection.memories.get(self.memory)
135        if bean is None:
136            ctx.log.warning("SetMemory: no memory %r", self.memory)
137            return
138        ctx.emit_trace(f"set memory {self.memory} → {self.value!r}")
139        await bean.set_value(self.value)
def extra_fields(self) -> dict[str, typing.Any]:
141    def extra_fields(self) -> dict[str, Any]:
142        return {"memory": self.memory, "value": self.value}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class SetSensorState(jmri_logixng.Action):
55@register
56class SetSensorState(Action):
57    """Set a sensor's known state (mostly for tests/simulation)."""
58
59    type_tag: ClassVar[str] = "action:set-sensor"
60
61    def __init__(
62        self,
63        *,
64        sensor: str,
65        state: SensorState,
66        user_name: str = "",
67        node_id: str | None = None,
68    ) -> None:
69        super().__init__(user_name=user_name, node_id=node_id)
70        self.sensor = sensor
71        self.state = state
72
73    async def execute(self, ctx: ExecutionContext) -> None:
74        bean = ctx.connection.sensors.get(self.sensor)
75        if bean is None:
76            ctx.log.warning("SetSensorState: no sensor %r", self.sensor)
77            return
78        ctx.emit_trace(f"set sensor {self.sensor} → {self.state.name}")
79        await bean._set_known_state(self.state)
80
81    def extra_fields(self) -> dict[str, Any]:
82        return {"sensor": self.sensor, "state": self.state.name}

Set a sensor's known state (mostly for tests/simulation).

SetSensorState( *, sensor: str, state: jmri_core.state.SensorState, user_name: str = '', node_id: str | None = None)
61    def __init__(
62        self,
63        *,
64        sensor: str,
65        state: SensorState,
66        user_name: str = "",
67        node_id: str | None = None,
68    ) -> None:
69        super().__init__(user_name=user_name, node_id=node_id)
70        self.sensor = sensor
71        self.state = state
type_tag: ClassVar[str] = 'action:set-sensor'
sensor
state
async def execute(self, ctx: ExecutionContext) -> None:
73    async def execute(self, ctx: ExecutionContext) -> None:
74        bean = ctx.connection.sensors.get(self.sensor)
75        if bean is None:
76            ctx.log.warning("SetSensorState: no sensor %r", self.sensor)
77            return
78        ctx.emit_trace(f"set sensor {self.sensor} → {self.state.name}")
79        await bean._set_known_state(self.state)
def extra_fields(self) -> dict[str, typing.Any]:
81    def extra_fields(self) -> dict[str, Any]:
82        return {"sensor": self.sensor, "state": self.state.name}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@register
class SetTurnoutState(jmri_logixng.Action):
25@register
26class SetTurnoutState(Action):
27    """Set a turnout's commanded state."""
28
29    type_tag: ClassVar[str] = "action:set-turnout"
30
31    def __init__(
32        self,
33        *,
34        turnout: str,
35        state: TurnoutState,
36        user_name: str = "",
37        node_id: str | None = None,
38    ) -> None:
39        super().__init__(user_name=user_name, node_id=node_id)
40        self.turnout = turnout
41        self.state = state
42
43    async def execute(self, ctx: ExecutionContext) -> None:
44        bean = ctx.connection.turnouts.get(self.turnout)
45        if bean is None:
46            ctx.log.warning("SetTurnoutState: no turnout %r", self.turnout)
47            return
48        ctx.emit_trace(f"set turnout {self.turnout} → {self.state.name}")
49        await bean.set_commanded_state(self.state)
50
51    def extra_fields(self) -> dict[str, Any]:
52        return {"turnout": self.turnout, "state": self.state.name}

Set a turnout's commanded state.

SetTurnoutState( *, turnout: str, state: jmri_core.state.TurnoutState, user_name: str = '', node_id: str | None = None)
31    def __init__(
32        self,
33        *,
34        turnout: str,
35        state: TurnoutState,
36        user_name: str = "",
37        node_id: str | None = None,
38    ) -> None:
39        super().__init__(user_name=user_name, node_id=node_id)
40        self.turnout = turnout
41        self.state = state
type_tag: ClassVar[str] = 'action:set-turnout'
turnout
state
async def execute(self, ctx: ExecutionContext) -> None:
43    async def execute(self, ctx: ExecutionContext) -> None:
44        bean = ctx.connection.turnouts.get(self.turnout)
45        if bean is None:
46            ctx.log.warning("SetTurnoutState: no turnout %r", self.turnout)
47            return
48        ctx.emit_trace(f"set turnout {self.turnout} → {self.state.name}")
49        await bean.set_commanded_state(self.state)
def extra_fields(self) -> dict[str, typing.Any]:
51    def extra_fields(self) -> dict[str, Any]:
52        return {"turnout": self.turnout, "state": self.state.name}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
@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: ConditionalNG, watches: list[tuple[typing.Literal['sensor', 'turnout', 'light', 'memory', 'block', 'reporter'], str]], guard: Optional[Expression[bool]] = None, user_name: str = '')
conditional: ConditionalNG
watches: list[tuple[typing.Literal['sensor', 'turnout', 'light', 'memory', 'block', 'reporter'], str]]
guard: Optional[Expression[bool]] = None
user_name: str = ''
@register
class TurnoutIsClosed(jmri_logixng.Expression[bool]):
56@register
57class TurnoutIsClosed(Expression[bool]):
58    """True when turnout *turnout* has known state CLOSED."""
59
60    type_tag: ClassVar[str] = "expr:turnout-closed"
61
62    def __init__(
63        self, *, turnout: str, user_name: str = "", node_id: str | None = None
64    ) -> None:
65        super().__init__(user_name=user_name, node_id=node_id)
66        self.turnout = turnout
67
68    async def evaluate(self, ctx: ExecutionContext) -> bool:
69        bean = ctx.connection.turnouts.get(self.turnout)
70        if bean is None:
71            ctx.log.warning("TurnoutIsClosed: no turnout %r", self.turnout)
72            return False
73        return bean.known_state == TurnoutState.CLOSED
74
75    def extra_fields(self) -> dict[str, Any]:
76        return {"turnout": self.turnout}

True when turnout turnout has known state CLOSED.

TurnoutIsClosed(*, turnout: str, user_name: str = '', node_id: str | None = None)
62    def __init__(
63        self, *, turnout: str, user_name: str = "", node_id: str | None = None
64    ) -> None:
65        super().__init__(user_name=user_name, node_id=node_id)
66        self.turnout = turnout
type_tag: ClassVar[str] = 'expr:turnout-closed'
turnout
async def evaluate(self, ctx: ExecutionContext) -> bool:
68    async def evaluate(self, ctx: ExecutionContext) -> bool:
69        bean = ctx.connection.turnouts.get(self.turnout)
70        if bean is None:
71            ctx.log.warning("TurnoutIsClosed: no turnout %r", self.turnout)
72            return False
73        return bean.known_state == TurnoutState.CLOSED
def extra_fields(self) -> dict[str, typing.Any]:
75    def extra_fields(self) -> dict[str, Any]:
76        return {"turnout": self.turnout}

Hook for subclasses to add their own fields to to_dict.

Inherited Members
Node
user_name
id
to_dict
__version__ = '0.1.0'
def from_dict(payload: dict[str, typing.Any]) -> Node:
75def from_dict(payload: dict[str, Any]) -> Node:
76    """Dispatch on ``type`` to the right decoder."""
77    tag = payload.get("type")
78    if not isinstance(tag, str):
79        raise ValueError(f"serialized node missing 'type' tag: {payload!r}")
80    decoder = _DECODERS.get(tag)
81    if decoder is None:
82        raise KeyError(f"no decoder for tag {tag!r}")
83    node = decoder(payload)
84    assert isinstance(node, Node)
85    return node

Dispatch on type to the right decoder.

def from_json(text: str) -> Node:
68def from_json(text: str) -> Node:
69    return from_dict(json.loads(text))
def from_yaml(text: str) -> Node:
64def from_yaml(text: str) -> Node:
65    return from_dict(yaml.safe_load(text))
def import_jmri_logixng_xml( source: str | pathlib._local.Path | bytes, out_dir: str | pathlib._local.Path | None = None) -> ImportResult:
115def import_jmri_logixng_xml(
116    source: str | pathlib.Path | bytes,
117    out_dir: str | pathlib.Path | None = None,
118) -> ImportResult:
119    """Read *source* and reconstruct our LogixNG trees.
120
121    *source* may be a filesystem path (``str`` / ``pathlib.Path``)
122    or raw XML bytes — the latter is convenient for wiring an
123    upload endpoint that hands us the file contents directly
124    without a temp file.
125
126    When *out_dir* is given, each converted program is also
127    serialized to ``<out_dir>/<slug>.yaml``. Returns an
128    :class:`ImportResult` carrying the in-memory programs, the
129    written files, and any :class:`ImportWarning` records for
130    unsupported nodes / addressing modes / dangling references.
131    """
132    if isinstance(source, bytes):
133        root = etree.fromstring(source)
134    else:
135        root = etree.parse(str(pathlib.Path(source))).getroot()
136
137    parser = _JmriLogixNGParser(root)
138    parser.build_index()
139    result = parser.walk_programs()
140
141    if out_dir is not None:
142        out_dir_p = pathlib.Path(out_dir)
143        out_dir_p.mkdir(parents=True, exist_ok=True)
144        for program in result.programs:
145            slug = _slugify(program.user_name or program.node_id or "logixng")
146            path = out_dir_p / f"{slug}.yaml"
147            save_yaml(program, path)
148            result.written.append(path)
149
150    return result

Read source and reconstruct our LogixNG trees.

source may be a filesystem path (str / pathlib.Path) or raw XML bytes — the latter is convenient for wiring an upload endpoint that hands us the file contents directly without a temp file.

When out_dir is given, each converted program is also serialized to <out_dir>/<slug>.yaml. Returns an ImportResult carrying the in-memory programs, the written files, and any ImportWarning records for unsupported nodes / addressing modes / dangling references.

def known_tags() -> list[str]:
49def known_tags() -> list[str]:
50    return sorted(_REGISTRY)
def load_yaml(path: str | pathlib._local.Path) -> Node:
60def load_yaml(path: str | pathlib.Path) -> Node:
61    return from_dict(yaml.safe_load(pathlib.Path(path).read_text(encoding="utf-8")))
def lookup(tag: str) -> type[Node]:
42def lookup(tag: str) -> type[Node]:
43    """Find the class for *tag*. Raises ``KeyError`` if unknown."""
44    if tag not in _REGISTRY:
45        raise KeyError(f"unknown LogixNG type tag: {tag!r}")
46    return _REGISTRY[tag]

Find the class for tag. Raises KeyError if unknown.

def register(cls: type[Node]) -> type[Node]:
24def register(cls: type[Node]) -> type[Node]:
25    """Register a node class by its ``type_tag``. Usable as a decorator.
26
27    Raises if the tag is already registered to a different class.
28    """
29    tag = cls.type_tag
30    if not tag:
31        raise ValueError(f"{cls.__name__}.type_tag must be set before registration")
32    existing = _REGISTRY.get(tag)
33    if existing is not None and existing is not cls:
34        raise ValueError(
35            f"type_tag {tag!r} already registered to {existing.__name__}, "
36            f"can't re-register to {cls.__name__}"
37        )
38    _REGISTRY[tag] = cls
39    return cls

Register a node class by its type_tag. Usable as a decorator.

Raises if the tag is already registered to a different class.

def save_yaml(node: Node, path: str | pathlib._local.Path) -> None:
56def save_yaml(node: Node, path: str | pathlib.Path) -> None:
57    pathlib.Path(path).write_text(to_yaml(node), encoding="utf-8")
def to_json(node: Node, *, indent: int | None = 2) -> str:
51def to_json(node: Node, *, indent: int | None = 2) -> str:
52    """Serialize *node* to a JSON string."""
53    return json.dumps(node.to_dict(), indent=indent)

Serialize node to a JSON string.

def to_yaml(node: Node) -> str:
46def to_yaml(node: Node) -> str:
47    """Serialize *node* to a YAML string. Lossless."""
48    return yaml.safe_dump(node.to_dict(), sort_keys=False)

Serialize node to a YAML string. Lossless.