jmri_logixng.jmri_importer
JMRI Java LogixNG XML → jmri_logixng YAML importer.
JMRI Java's LogixNG persistence layout: the XML file (typically a
PanelPro.xml with a <LogixNGs> section) is a flat forest of
manager-scoped elements cross-referenced by system name:
<LogixNGs>— every LogixNG program, with its conditional NGs referenced by system name.<LogixNGConditionalNGs>— every ConditionalNG's root socket references the head action by system name.<LogixNGDigitalActions>/<LogixNGDigitalExpressions>— every action/expression node, with its children (also by system name).
Reifying our nested tree means:
- Index every node in the four manager sections by system name.
- Walk from each
<LogixNG>root → its ConditionalNGs → each conditional's root action → recursively resolve child sockets to nodes in the index. - Dispatch each XML tag (
ActionTurnout,ExpressionSensor,And, …) to a handler that produces the matching jmri_logixngNode. - Serialize each converted LogixNG to
<out_dir>/<system_name_slug>.yamlusing our normal~jmri_logixng.serialize.save_yaml().
Unknown tags are surfaced as ~jmri_logixng.actions.digital.LogAction
placeholders whose message names the missing tag; the import
returns a list of ImportWarning records so the caller can
report gaps to the operator. This keeps the "import a whole panel"
workflow going instead of aborting on the first unsupported node.
What's supported today (matches our current node set — Stage 7 gaps are still open):
Actions
ActionTurnout, ActionSensor, ActionLight,
ActionMemory, ActionLog / LogLocalVariables,
IfThenElse, DigitalMany (→ Sequence),
DigitalActionTimer (→ Delay, one-branch simple form).
Expressions
ExpressionTurnout, ExpressionSensor, ExpressionLight,
ExpressionMemory, True / False, And, Or,
Not.
Every action/expression carries a SelectNamedBean /
SelectEnum wrapper in JMRI Java that supports Direct /
Reference / LocalVariable / Formula addressing. This importer only
recognises Direct addressing (the common case — a static
system name and a static enum value). Non-Direct addressing degrades
to a warning + a best-effort static value.
1"""JMRI Java LogixNG XML → jmri_logixng YAML importer. 2 3JMRI Java's LogixNG persistence layout: the XML file (typically a 4PanelPro.xml with a ``<LogixNGs>`` section) is a **flat forest** of 5manager-scoped elements cross-referenced by system name: 6 7* ``<LogixNGs>`` — every LogixNG program, with its 8 conditional NGs referenced by system name. 9* ``<LogixNGConditionalNGs>`` — every ConditionalNG's root socket 10 references the head action by system name. 11* ``<LogixNGDigitalActions>`` / ``<LogixNGDigitalExpressions>`` — 12 every action/expression node, with its 13 children (also by system name). 14 15Reifying our nested tree means: 16 171. **Index** every node in the four manager sections by system name. 182. **Walk** from each ``<LogixNG>`` root → its ConditionalNGs → each 19 conditional's root action → recursively resolve child sockets to 20 nodes in the index. 213. **Dispatch** each XML tag (``ActionTurnout``, ``ExpressionSensor``, 22 ``And``, …) to a handler that produces the matching jmri_logixng 23 :class:`Node`. 244. **Serialize** each converted LogixNG to 25 ``<out_dir>/<system_name_slug>.yaml`` using our normal 26 :func:`~jmri_logixng.serialize.save_yaml`. 27 28Unknown tags are surfaced as :class:`~jmri_logixng.actions.digital.LogAction` 29placeholders whose ``message`` names the missing tag; the import 30returns a list of :class:`ImportWarning` records so the caller can 31report gaps to the operator. This keeps the "import a whole panel" 32workflow going instead of aborting on the first unsupported node. 33 34**What's supported today** (matches our current node set — Stage 7 35gaps are still open): 36 37Actions 38 ``ActionTurnout``, ``ActionSensor``, ``ActionLight``, 39 ``ActionMemory``, ``ActionLog`` / ``LogLocalVariables``, 40 ``IfThenElse``, ``DigitalMany`` (→ Sequence), 41 ``DigitalActionTimer`` (→ Delay, one-branch simple form). 42 43Expressions 44 ``ExpressionTurnout``, ``ExpressionSensor``, ``ExpressionLight``, 45 ``ExpressionMemory``, ``True`` / ``False``, ``And``, ``Or``, 46 ``Not``. 47 48Every action/expression carries a ``SelectNamedBean`` / 49``SelectEnum`` wrapper in JMRI Java that supports Direct / 50Reference / LocalVariable / Formula addressing. This importer only 51recognises **Direct** addressing (the common case — a static 52system name and a static enum value). Non-Direct addressing degrades 53to a warning + a best-effort static value. 54""" 55from __future__ import annotations 56 57import logging 58import pathlib 59import re 60from dataclasses import dataclass, field 61from typing import Any 62 63from lxml import etree 64 65from jmri_core.state import LightState, SensorState, TurnoutState 66 67from jmri_logixng.actions.digital import ( 68 IfThenElse, 69 LogAction, 70 SetLightState, 71 SetMemory, 72 SetSensorState, 73 SetTurnoutState, 74) 75from jmri_logixng.actions.flow import Delay, Sequence 76from jmri_logixng.expressions.digital import ( 77 And, 78 ConstantBool, 79 LightIsOn, 80 MemoryEquals, 81 Not, 82 Or, 83 SensorIsActive, 84 TurnoutIsClosed, 85) 86from jmri_logixng.logix import ConditionalNG, LogixNG 87from jmri_logixng.serialize import save_yaml 88from jmri_logixng.tree import Action, Expression, Node 89 90_log = logging.getLogger(__name__) 91 92 93@dataclass(frozen=True, slots=True) 94class ImportWarning: 95 """One diagnostic the importer recorded during a run.""" 96 97 kind: str # e.g. "unknown-tag", "non-direct-addressing" 98 detail: str # human-readable specifics 99 system_name: str # the JMRI system name that produced it 100 101 102@dataclass(slots=True) 103class ImportResult: 104 """Outcome of :func:`import_jmri_logixng_xml`.""" 105 106 programs: list[LogixNG] = field(default_factory=list) 107 written: list[pathlib.Path] = field(default_factory=list) 108 warnings: list[ImportWarning] = field(default_factory=list) 109 110 111# ---- parse phase ------------------------------------------------------- 112 113 114def import_jmri_logixng_xml( 115 source: str | pathlib.Path | bytes, 116 out_dir: str | pathlib.Path | None = None, 117) -> ImportResult: 118 """Read *source* and reconstruct our LogixNG trees. 119 120 *source* may be a filesystem path (``str`` / ``pathlib.Path``) 121 or raw XML bytes — the latter is convenient for wiring an 122 upload endpoint that hands us the file contents directly 123 without a temp file. 124 125 When *out_dir* is given, each converted program is also 126 serialized to ``<out_dir>/<slug>.yaml``. Returns an 127 :class:`ImportResult` carrying the in-memory programs, the 128 written files, and any :class:`ImportWarning` records for 129 unsupported nodes / addressing modes / dangling references. 130 """ 131 if isinstance(source, bytes): 132 root = etree.fromstring(source) 133 else: 134 root = etree.parse(str(pathlib.Path(source))).getroot() 135 136 parser = _JmriLogixNGParser(root) 137 parser.build_index() 138 result = parser.walk_programs() 139 140 if out_dir is not None: 141 out_dir_p = pathlib.Path(out_dir) 142 out_dir_p.mkdir(parents=True, exist_ok=True) 143 for program in result.programs: 144 slug = _slugify(program.user_name or program.node_id or "logixng") 145 path = out_dir_p / f"{slug}.yaml" 146 save_yaml(program, path) 147 result.written.append(path) 148 149 return result 150 151 152_SLUG_RE = re.compile(r"[^A-Za-z0-9._-]+") 153 154 155def _slugify(name: str) -> str: 156 return _SLUG_RE.sub("_", name).strip("_") or "logixng" 157 158 159class _JmriLogixNGParser: 160 """The stateful heavy lifter for :func:`import_jmri_logixng_xml`. 161 162 Keeps the four manager-section indexes + a fresh warnings list. 163 Callers use the two-phase API: :meth:`build_index` first, then 164 :meth:`walk_programs`. 165 """ 166 167 def __init__(self, root: etree._Element) -> None: 168 self._root = root 169 # System-name → element indexes for the two node managers + 170 # the two container managers. 171 self._logixngs: dict[str, etree._Element] = {} 172 self._conditionalngs: dict[str, etree._Element] = {} 173 self._actions: dict[str, etree._Element] = {} 174 self._expressions: dict[str, etree._Element] = {} 175 self.warnings: list[ImportWarning] = [] 176 177 # ---- indexing ------------------------------------------------------ 178 179 def build_index(self) -> None: 180 """Walk the top-level manager sections + populate indexes. 181 182 Supports both: 183 184 * The one-file layout where ``<LogixNGs>`` etc. are direct 185 children of a ``<layout-config>``. 186 * The nested layout where the same sections are inside a 187 ``<layoutParameters>`` wrapper. (Some JMRI panel files 188 use this form for encapsulation.) 189 """ 190 self._index_section("LogixNGs", "LogixNG", self._logixngs) 191 self._index_section( 192 "LogixNGConditionalNGs", "ConditionalNG", self._conditionalngs, 193 ) 194 self._index_section( 195 "LogixNGDigitalActions", None, self._actions, 196 ) 197 self._index_section( 198 "LogixNGDigitalExpressions", None, self._expressions, 199 ) 200 201 def _index_section( 202 self, 203 section_tag: str, 204 child_tag: str | None, 205 target: dict[str, etree._Element], 206 ) -> None: 207 """Fill *target* from the named manager section. 208 209 *child_tag* narrows to only that XML tag when supplied — used 210 for ``LogixNGs`` / ``LogixNGConditionalNGs`` where the section 211 holds entries of exactly one type. For actions and 212 expressions the section holds a heterogeneous mix of tags 213 (``ActionTurnout``, ``ActionSensor``, …), so passing None 214 indexes every child. 215 """ 216 for section in self._root.iter(section_tag): 217 for child in section: 218 if child_tag is not None and child.tag != child_tag: 219 continue 220 sn = child.findtext("systemName") 221 if sn: 222 target[sn] = child 223 224 # ---- walk --------------------------------------------------------- 225 226 def walk_programs(self) -> ImportResult: 227 """Reify every indexed ``<LogixNG>`` into a converted 228 :class:`LogixNG` tree.""" 229 result = ImportResult(warnings=self.warnings) 230 for system_name, element in self._logixngs.items(): 231 program = self._convert_logixng(system_name, element) 232 result.programs.append(program) 233 return result 234 235 def _convert_logixng( 236 self, system_name: str, element: etree._Element, 237 ) -> LogixNG: 238 user_name = element.findtext("userName") or "" 239 conditionals: list[ConditionalNG] = [] 240 conditional_names_elem = element.find("ConditionalNGs") 241 if conditional_names_elem is not None: 242 for cn_ref in conditional_names_elem.findall("systemName"): 243 ref_sn = (cn_ref.text or "").strip() 244 cn_element = self._conditionalngs.get(ref_sn) 245 if cn_element is None: 246 self.warnings.append(ImportWarning( 247 kind="dangling-conditional-ref", 248 detail=f"LogixNG {system_name!r} references missing ConditionalNG {ref_sn!r}", 249 system_name=system_name, 250 )) 251 continue 252 conditionals.append(self._convert_conditionalng(cn_element)) 253 return LogixNG( 254 user_name=user_name, 255 node_id=system_name, 256 conditionals=conditionals, 257 ) 258 259 def _convert_conditionalng( 260 self, element: etree._Element, 261 ) -> ConditionalNG: 262 system_name = element.findtext("systemName") or "" 263 user_name = element.findtext("userName") or "" 264 # ConditionalNG has one root socket → an Action. 265 root_action = self._follow_socket(element.find("Socket"), Action) 266 actions: list[Action] = [root_action] if root_action is not None else [] 267 return ConditionalNG( 268 user_name=user_name, node_id=system_name, actions=actions, 269 ) 270 271 # ---- socket helpers ------------------------------------------------ 272 273 def _follow_socket( 274 self, socket: etree._Element | None, expected: type, 275 ) -> Any: 276 """Resolve a ``<Socket>`` element to the child :class:`Node`. 277 278 Returns ``None`` when the socket is unconnected — that's 279 legitimate in JMRI ("empty branch"). Type mismatch (an 280 Action socket resolved to an Expression element) becomes a 281 warning + None; better than a crash mid-import. 282 """ 283 if socket is None: 284 return None 285 ref = socket.findtext("systemName") 286 if not ref: 287 return None 288 ref = ref.strip() 289 source_dict = ( 290 self._actions if expected is Action else self._expressions 291 ) 292 element = source_dict.get(ref) 293 if element is None: 294 self.warnings.append(ImportWarning( 295 kind="dangling-socket-ref", 296 detail=f"socket references missing {expected.__name__} {ref!r}", 297 system_name=ref, 298 )) 299 return None 300 return self._convert_node(ref, element, expected) 301 302 def _convert_node( 303 self, system_name: str, element: etree._Element, expected: type, 304 ) -> Node | None: 305 tag = element.tag 306 handler = _NODE_HANDLERS.get(tag) 307 if handler is None: 308 self.warnings.append(ImportWarning( 309 kind="unknown-tag", 310 detail=f"no importer for XML tag {tag!r}", 311 system_name=system_name, 312 )) 313 return self._placeholder_for(expected, tag, system_name) 314 try: 315 return handler(self, element, system_name) 316 except Exception as e: # noqa: BLE001 317 self.warnings.append(ImportWarning( 318 kind="handler-failed", 319 detail=f"{tag} handler raised: {e}", 320 system_name=system_name, 321 )) 322 return self._placeholder_for(expected, tag, system_name) 323 324 def _placeholder_for( 325 self, expected: type, tag: str, system_name: str, 326 ) -> Node: 327 """Return a node of the right kind so the tree stays valid 328 even when we don't have a mapping. LogAction fills the 329 Action slot; ConstantBool(False) fills the Expression slot.""" 330 if expected is Action: 331 return LogAction( 332 message=f"imported placeholder: unsupported LogixNG XML tag {tag!r}", 333 level="warning", 334 user_name=f"placeholder for {system_name}", 335 node_id=None, 336 ) 337 return ConstantBool( 338 value=False, user_name=f"placeholder for {system_name}", 339 node_id=None, 340 ) 341 342 343# ---- field helpers ------------------------------------------------------ 344 345 346def _named_bean_sysname( 347 parser: _JmriLogixNGParser, wrapper: etree._Element | None, 348 system_name: str, field_name: str, 349) -> str: 350 """Pull the target bean's system name out of a 351 ``<namedBean>`` wrapper. Warns on non-Direct addressing.""" 352 if wrapper is None: 353 return "" 354 addressing = wrapper.findtext("addressing") 355 if addressing and addressing != "Direct": 356 parser.warnings.append(ImportWarning( 357 kind="non-direct-addressing", 358 detail=f"field {field_name!r} uses {addressing!r} addressing; " 359 "only Direct is imported", 360 system_name=system_name, 361 )) 362 return (wrapper.findtext("systemName") or "").strip() 363 364 365def _selected_enum( 366 parser: _JmriLogixNGParser, wrapper: etree._Element | None, 367 system_name: str, field_name: str, 368) -> str: 369 """Pull the ``<enum>`` value out of a state wrapper.""" 370 if wrapper is None: 371 return "" 372 addressing = wrapper.findtext("addressing") 373 if addressing and addressing != "Direct": 374 parser.warnings.append(ImportWarning( 375 kind="non-direct-addressing", 376 detail=f"enum {field_name!r} uses {addressing!r} addressing", 377 system_name=system_name, 378 )) 379 return (wrapper.findtext("enum") or "").strip() 380 381 382# ---- individual node handlers ------------------------------------------ 383 384 385def _handle_action_turnout( 386 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 387) -> SetTurnoutState: 388 turnout = _named_bean_sysname( 389 parser, element.find("namedBean"), system_name, "turnout", 390 ) 391 state = _selected_enum( 392 parser, element.find("state"), system_name, "state", 393 ) or "CLOSED" 394 return SetTurnoutState( 395 turnout=turnout, state=_turnout_state(state), 396 user_name=element.findtext("userName") or "", 397 node_id=system_name, 398 ) 399 400 401def _handle_action_sensor( 402 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 403) -> SetSensorState: 404 sensor = _named_bean_sysname( 405 parser, element.find("namedBean"), system_name, "sensor", 406 ) 407 state = _selected_enum( 408 parser, element.find("state"), system_name, "state", 409 ) or "INACTIVE" 410 return SetSensorState( 411 sensor=sensor, state=_sensor_state(state), 412 user_name=element.findtext("userName") or "", 413 node_id=system_name, 414 ) 415 416 417def _handle_action_light( 418 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 419) -> SetLightState: 420 light = _named_bean_sysname( 421 parser, element.find("namedBean"), system_name, "light", 422 ) 423 state = _selected_enum( 424 parser, element.find("state"), system_name, "state", 425 ) or "OFF" 426 return SetLightState( 427 light=light, state=_light_state(state), 428 user_name=element.findtext("userName") or "", 429 node_id=system_name, 430 ) 431 432 433def _handle_action_memory( 434 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 435) -> SetMemory: 436 memory = _named_bean_sysname( 437 parser, element.find("memoryNamedBean") or element.find("namedBean"), 438 system_name, "memory", 439 ) 440 value = element.findtext("otherConstantValue") or element.findtext("data") or None 441 return SetMemory( 442 memory=memory, value=value, 443 user_name=element.findtext("userName") or "", 444 node_id=system_name, 445 ) 446 447 448def _handle_action_log( 449 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 450) -> LogAction: 451 message = element.findtext("message") or "" 452 level = (element.findtext("logLevel") or "info").lower() 453 return LogAction( 454 message=message, level=level, 455 user_name=element.findtext("userName") or "", 456 node_id=system_name, 457 ) 458 459 460def _handle_if_then_else( 461 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 462) -> IfThenElse: 463 # JMRI's IfThenElse allows N expressions × N actions; we take 464 # the first expression + first two actions (then/else). 465 expressions_elem = element.find("Expressions") 466 actions_elem = element.find("Actions") 467 condition = None 468 if expressions_elem is not None: 469 first = expressions_elem.find("Socket") 470 condition = parser._follow_socket(first, Expression) 471 then_action = None 472 else_action = None 473 if actions_elem is not None: 474 sockets = actions_elem.findall("Socket") 475 if sockets: 476 then_action = parser._follow_socket(sockets[0], Action) 477 if len(sockets) > 1: 478 else_action = parser._follow_socket(sockets[1], Action) 479 if len(sockets) > 2: 480 parser.warnings.append(ImportWarning( 481 kind="multi-branch-truncated", 482 detail=f"IfThenElse has {len(sockets)} action branches; " 483 "only the first two (then/else) are kept", 484 system_name=system_name, 485 )) 486 if condition is None: 487 condition = ConstantBool(value=False, node_id=None) 488 if then_action is None: 489 then_action = LogAction( 490 message="imported IfThenElse: empty 'then' branch", 491 level="debug", node_id=None, 492 ) 493 return IfThenElse( 494 condition=condition, then_action=then_action, else_action=else_action, 495 user_name=element.findtext("userName") or "", 496 node_id=system_name, 497 ) 498 499 500def _handle_digital_many( 501 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 502) -> Sequence: 503 """DigitalMany (a.k.a. sequence) — run all connected sockets in 504 order. Maps directly to our :class:`Sequence`.""" 505 actions: list[Action] = [] 506 actions_elem = element.find("Actions") or element.find("SocketList") 507 if actions_elem is not None: 508 for socket in actions_elem.findall("Socket"): 509 child = parser._follow_socket(socket, Action) 510 if child is not None: 511 actions.append(child) 512 return Sequence( 513 actions=actions, 514 user_name=element.findtext("userName") or "", 515 node_id=system_name, 516 ) 517 518 519def _handle_digital_timer( 520 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 521) -> Delay: 522 """DigitalActionTimer / DigitalActionTimerSocket → our simple 523 Delay. JMRI Java's timer is far richer (multiple stages, action 524 per stage) — we take the first duration and warn.""" 525 seconds = 0.0 526 duration_text = ( 527 element.findtext("duration") 528 or element.findtext("Duration") 529 or element.findtext("delay") 530 or "" 531 ) 532 try: 533 seconds = float(duration_text) / 1000.0 if duration_text else 0.0 534 except ValueError: 535 seconds = 0.0 536 return Delay( 537 seconds=seconds, 538 user_name=element.findtext("userName") or "", 539 node_id=system_name, 540 ) 541 542 543# ---- expression handlers ----------------------------------------------- 544 545 546def _handle_expression_sensor( 547 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 548) -> Node: 549 sensor = _named_bean_sysname( 550 parser, element.find("namedBean"), system_name, "sensor", 551 ) 552 state = _selected_enum( 553 parser, element.find("state"), system_name, "state", 554 ) or "ACTIVE" 555 # JMRI supports IS/IS_NOT + arbitrary target state; we model 556 # "IS ACTIVE" and negate for IS_NOT. 557 is_isnot = (element.findtext("is_isNot") or "IS").upper() 558 node: Node = SensorIsActive( 559 sensor=sensor, 560 user_name=element.findtext("userName") or "", 561 node_id=system_name, 562 ) 563 if state != "ACTIVE": 564 parser.warnings.append(ImportWarning( 565 kind="expression-state-approximated", 566 detail=f"ExpressionSensor {system_name}: modelling non-ACTIVE " 567 "target as SensorIsActive; will not match for INACTIVE", 568 system_name=system_name, 569 )) 570 if is_isnot == "IS_NOT": 571 node = Not(child=node, node_id=None) # type: ignore[arg-type] 572 return node 573 574 575def _handle_expression_turnout( 576 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 577) -> Node: 578 turnout = _named_bean_sysname( 579 parser, element.find("namedBean"), system_name, "turnout", 580 ) 581 state = _selected_enum( 582 parser, element.find("state"), system_name, "state", 583 ) or "CLOSED" 584 is_isnot = (element.findtext("is_isNot") or "IS").upper() 585 node: Node = TurnoutIsClosed( 586 turnout=turnout, 587 user_name=element.findtext("userName") or "", 588 node_id=system_name, 589 ) 590 if state != "CLOSED": 591 parser.warnings.append(ImportWarning( 592 kind="expression-state-approximated", 593 detail=f"ExpressionTurnout {system_name}: modelling non-CLOSED " 594 "target as TurnoutIsClosed", 595 system_name=system_name, 596 )) 597 if is_isnot == "IS_NOT": 598 node = Not(child=node, node_id=None) # type: ignore[arg-type] 599 return node 600 601 602def _handle_expression_light( 603 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 604) -> Node: 605 light = _named_bean_sysname( 606 parser, element.find("namedBean"), system_name, "light", 607 ) 608 is_isnot = (element.findtext("is_isNot") or "IS").upper() 609 node: Node = LightIsOn( 610 light=light, 611 user_name=element.findtext("userName") or "", 612 node_id=system_name, 613 ) 614 if is_isnot == "IS_NOT": 615 node = Not(child=node, node_id=None) # type: ignore[arg-type] 616 return node 617 618 619def _handle_expression_memory( 620 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 621) -> MemoryEquals: 622 memory = _named_bean_sysname( 623 parser, element.find("namedBean"), system_name, "memory", 624 ) 625 value = element.findtext("constantValue") or element.findtext("data") or None 626 return MemoryEquals( 627 memory=memory, value=value, 628 user_name=element.findtext("userName") or "", 629 node_id=system_name, 630 ) 631 632 633def _handle_true( 634 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 635) -> ConstantBool: 636 return ConstantBool( 637 value=True, user_name=element.findtext("userName") or "", 638 node_id=system_name, 639 ) 640 641 642def _handle_false( 643 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 644) -> ConstantBool: 645 return ConstantBool( 646 value=False, user_name=element.findtext("userName") or "", 647 node_id=system_name, 648 ) 649 650 651def _handle_and( 652 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 653) -> And: 654 return And( 655 children=_collect_expression_children(parser, element), 656 user_name=element.findtext("userName") or "", 657 node_id=system_name, 658 ) 659 660 661def _handle_or( 662 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 663) -> Or: 664 return Or( 665 children=_collect_expression_children(parser, element), 666 user_name=element.findtext("userName") or "", 667 node_id=system_name, 668 ) 669 670 671def _handle_not( 672 parser: _JmriLogixNGParser, element: etree._Element, system_name: str, 673) -> Not: 674 child_expr = None 675 sockets_elem = element.find("Expressions") or element 676 for socket in sockets_elem.findall("Socket"): 677 child_expr = parser._follow_socket(socket, Expression) 678 if child_expr is not None: 679 break 680 if child_expr is None: 681 child_expr = ConstantBool(value=False, node_id=None) 682 return Not( 683 child=child_expr, # type: ignore[arg-type] 684 user_name=element.findtext("userName") or "", 685 node_id=system_name, 686 ) 687 688 689def _collect_expression_children( 690 parser: _JmriLogixNGParser, element: etree._Element, 691) -> list[Expression[Any]]: 692 children: list[Expression[Any]] = [] 693 exprs_elem = element.find("Expressions") 694 if exprs_elem is None: 695 return children 696 for socket in exprs_elem.findall("Socket"): 697 child = parser._follow_socket(socket, Expression) 698 if child is not None: 699 children.append(child) # type: ignore[arg-type] 700 return children 701 702 703# ---- state translation -------------------------------------------------- 704 705def _turnout_state(name: str) -> TurnoutState: 706 """Map JMRI enum values (``CLOSED`` / ``THROWN`` / ``UNKNOWN``) to 707 our TurnoutState. Unknown → CLOSED (least-surprise default).""" 708 try: 709 return TurnoutState[name.upper()] 710 except KeyError: 711 return TurnoutState.CLOSED 712 713 714def _sensor_state(name: str) -> SensorState: 715 try: 716 return SensorState[name.upper()] 717 except KeyError: 718 return SensorState.INACTIVE 719 720 721def _light_state(name: str) -> LightState: 722 try: 723 return LightState[name.upper()] 724 except KeyError: 725 return LightState.OFF 726 727 728# ---- tag dispatch registry --------------------------------------------- 729 730# The XML tag names come straight from JMRI Java's 731# ``configurexml/*Xml.java`` handlers (see the ``new Element(...)`` 732# calls quoted at the top of each). Every tag matches an 733# ``ActionXxx`` or ``ExpressionXxx`` class in JMRI Java. 734_NODE_HANDLERS: dict[str, Any] = { 735 # Actions 736 "ActionTurnout": _handle_action_turnout, 737 "ActionSensor": _handle_action_sensor, 738 "ActionLight": _handle_action_light, 739 "ActionMemory": _handle_action_memory, 740 "LogLocalVariables": _handle_action_log, 741 "IfThenElse": _handle_if_then_else, 742 "DigitalMany": _handle_digital_many, 743 "DigitalActionTimer": _handle_digital_timer, 744 # Expressions 745 "ExpressionSensor": _handle_expression_sensor, 746 "ExpressionTurnout": _handle_expression_turnout, 747 "ExpressionLight": _handle_expression_light, 748 "ExpressionMemory": _handle_expression_memory, 749 "True": _handle_true, 750 "False": _handle_false, 751 "And": _handle_and, 752 "Or": _handle_or, 753 "Not": _handle_not, 754}
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.
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().
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.