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:

  1. Index every node in the four manager sections by system name.
  2. Walk from each <LogixNG> root → its ConditionalNGs → each conditional's root action → recursively resolve child sockets to nodes in the index.
  3. Dispatch each XML tag (ActionTurnout, ExpressionSensor, And, …) to a handler that produces the matching jmri_logixng Node.
  4. Serialize each converted LogixNG to <out_dir>/<system_name_slug>.yaml using 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}
@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
@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[jmri_logixng.LogixNG] = <factory>, written: list[pathlib._local.Path] = <factory>, warnings: list[ImportWarning] = <factory>)
programs: list[jmri_logixng.LogixNG]
written: list[pathlib._local.Path]
warnings: list[ImportWarning]
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.