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]
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.
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.
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.
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.
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
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)
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.
Hook for subclasses to add their own fields to to_dict.
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).
Hook for subclasses to add their own fields to to_dict.
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.
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().
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.
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
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.
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)
Outcome of import_jmri_logixng_xml().
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.
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.
Hook for subclasses to add their own fields to to_dict.
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.
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.
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
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).
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.
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
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).
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.
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.
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.
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.
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.
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.
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.
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.
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().
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.
Hook for subclasses to add their own fields to to_dict.
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.
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.
Hook for subclasses to add their own fields to to_dict.
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.
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.
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)
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.
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)
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).
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)
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.
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)
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.
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.
Hook for subclasses to add their own fields to to_dict.
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.
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.
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.
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.
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.
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.