jmri_server.config_store

ConfigStore — server-owned Configuration.xml bean persistence.

Holds the parsed bean tables (turnouts, sensors, lights) from the layout-config Configuration.xml file in the active profile. Other sections (signal heads, memories, routes, blocks, panels, LogixNG) are preserved verbatim so users can keep their existing JMRI configs without losing untouched data when our web UI saves changes.

Storage policy:

  • One Configuration.xml at Profile.config_file.
  • On startup: read the file once, populate in-memory state.
  • On every bean mutation: rewrite the whole file.
  • Atomic write (write to .tmp, rename) so a crash mid-write can never lose data.
  • In-memory state is the source of truth during a server session; the XML file is the source of truth at startup.
  1"""``ConfigStore`` — server-owned ``Configuration.xml`` bean persistence.
  2
  3Holds the parsed bean tables (turnouts, sensors, lights) from the
  4``layout-config`` Configuration.xml file in the active profile. Other
  5sections (signal heads, memories, routes, blocks, panels, LogixNG)
  6are preserved verbatim so users can keep their existing JMRI configs
  7without losing untouched data when our web UI saves changes.
  8
  9Storage policy:
 10
 11* One Configuration.xml at ``Profile.config_file``.
 12* On startup: read the file once, populate in-memory state.
 13* On every bean mutation: rewrite the whole file.
 14* Atomic write (write to ``.tmp``, rename) so a crash mid-write can
 15  never lose data.
 16* In-memory state is the source of truth during a server session;
 17  the XML file is the source of truth at startup.
 18"""
 19from __future__ import annotations
 20
 21import logging
 22import os
 23import pathlib
 24from collections.abc import Iterable
 25from dataclasses import dataclass, field
 26
 27from jmri_core.persistence import (
 28    ConfigFile,
 29    LightRow,
 30    LightSection,
 31    SensorRow,
 32    SensorSection,
 33    TurnoutRow,
 34    TurnoutSection,
 35    empty_config,
 36    light_mgr_class_for_prefix,
 37    load_config,
 38    save_config_bytes,
 39    sensor_mgr_class_for_prefix,
 40    turnout_mgr_class_for_prefix,
 41)
 42from jmri_core.persistence.profile import Profile
 43
 44_log = logging.getLogger(__name__)
 45
 46# Rows are grouped by system-name prefix at flush time so each JMRI-style
 47# ``<turnouts class="...">`` block carries a *concrete* driver-specific
 48# manager class (e.g. ``NceTurnoutManagerXml``). The abstract class names
 49# (``AbstractTurnoutManagerConfigXML`` etc.) that used to live here are
 50# schema-invalid — they end in uppercase ``L`` and JMRI Java refuses to
 51# parse them at startup.
 52
 53
 54def _system_prefix(system_name: str) -> str:
 55    """The JMRI system-prefix character — first letter of the system name."""
 56    return system_name[:1].upper() if system_name else "I"
 57
 58
 59def _group_by_prefix[R](rows: Iterable[R]) -> list[tuple[str, list[R]]]:
 60    """Group *rows* by their system-name prefix.
 61
 62    Returns ``[(prefix, sorted_rows), ...]`` with stable ordering
 63    (prefixes alphabetical, rows sorted by system_name within each
 64    group). One returned tuple maps to one ``<turnouts>`` / ``<sensors>``
 65    / ``<lights>`` block in the emitted XML — JMRI writes one block per
 66    connection prefix, and matching that layout keeps files loadable in
 67    JMRI Java.
 68    """
 69    buckets: dict[str, list[R]] = {}
 70    for row in rows:
 71        buckets.setdefault(_system_prefix(row.system_name), []).append(row)  # type: ignore[attr-defined]
 72    return [
 73        (prefix, sorted(buckets[prefix], key=lambda r: r.system_name))  # type: ignore[attr-defined]
 74        for prefix in sorted(buckets)
 75    ]
 76
 77
 78@dataclass
 79class ConfigStore:
 80    """In-memory + on-disk Configuration.xml."""
 81
 82    profile: Profile
 83    _turnouts: dict[str, TurnoutRow] = field(default_factory=dict)
 84    _sensors: dict[str, SensorRow] = field(default_factory=dict)
 85    _lights: dict[str, LightRow] = field(default_factory=dict)
 86    _preserved_doctype: str | None = None
 87    _preserved_root_attribs: dict[str, str] = field(default_factory=dict)
 88    _preserved_elements: list = field(default_factory=list)
 89
 90    def __post_init__(self) -> None:
 91        self.profile.ensure()
 92        self._load_existing()
 93
 94    def _load_existing(self) -> None:
 95        """Load the on-disk Configuration.xml into memory, if it exists."""
 96        path = self.profile.config_file
 97        if not path.exists():
 98            cfg = empty_config()
 99            self._preserved_doctype = cfg.doctype
100            return
101        try:
102            cfg = load_config(path)
103        except Exception as e:  # noqa: BLE001
104            _log.warning(
105                "ConfigStore: failed to parse %s (%s); starting empty", path, e
106            )
107            self._preserved_doctype = empty_config().doctype
108            return
109        for row in cfg.all_turnouts():
110            self._turnouts[row.system_name] = row
111        for row in cfg.all_sensors():
112            self._sensors[row.system_name] = row
113        for row in cfg.all_lights():
114            self._lights[row.system_name] = row
115        self._preserved_doctype = cfg.doctype
116        self._preserved_root_attribs = dict(cfg.root_attribs)
117        self._preserved_elements = list(cfg.preserved)
118
119    # ----- read --------------------------------------------------------
120
121    def turnouts(self) -> Iterable[TurnoutRow]:
122        return self._turnouts.values()
123
124    def sensors(self) -> Iterable[SensorRow]:
125        return self._sensors.values()
126
127    def lights(self) -> Iterable[LightRow]:
128        return self._lights.values()
129
130    # ----- write -------------------------------------------------------
131
132    def upsert_turnout(self, row: TurnoutRow) -> None:
133        # Preserve unmodeled extras (locked / lockMode / decoder etc.)
134        # from any existing row — the bean services don't carry these
135        # fields and shouldn't accidentally erase them on edit.
136        prev = self._turnouts.get(row.system_name)
137        if prev and not row.extra_attribs and prev.extra_attribs:
138            row.extra_attribs = dict(prev.extra_attribs)
139        if prev and row.comment is None and prev.comment:
140            row.comment = prev.comment
141        self._turnouts[row.system_name] = row
142        self._flush()
143
144    def upsert_sensor(self, row: SensorRow) -> None:
145        prev = self._sensors.get(row.system_name)
146        if prev and not row.extra_attribs and prev.extra_attribs:
147            row.extra_attribs = dict(prev.extra_attribs)
148        if prev and row.comment is None and prev.comment:
149            row.comment = prev.comment
150        self._sensors[row.system_name] = row
151        self._flush()
152
153    def upsert_light(self, row: LightRow) -> None:
154        prev = self._lights.get(row.system_name)
155        if prev and not row.extra_attribs and prev.extra_attribs:
156            row.extra_attribs = dict(prev.extra_attribs)
157        if prev and row.comment is None and prev.comment:
158            row.comment = prev.comment
159        self._lights[row.system_name] = row
160        self._flush()
161
162    def remove_turnout(self, system_name: str) -> bool:
163        existed = self._turnouts.pop(system_name, None) is not None
164        if existed:
165            self._flush()
166        return existed
167
168    def remove_sensor(self, system_name: str) -> bool:
169        existed = self._sensors.pop(system_name, None) is not None
170        if existed:
171            self._flush()
172        return existed
173
174    def remove_light(self, system_name: str) -> bool:
175        existed = self._lights.pop(system_name, None) is not None
176        if existed:
177            self._flush()
178        return existed
179
180    # ----- I/O ---------------------------------------------------------
181
182    def _build_config(self) -> ConfigFile:
183        return ConfigFile(
184            root_attribs=dict(self._preserved_root_attribs),
185            doctype=self._preserved_doctype,
186            turnout_sections=self._build_turnout_sections(),
187            sensor_sections=self._build_sensor_sections(),
188            light_sections=self._build_light_sections(),
189            preserved=list(self._preserved_elements),
190        )
191
192    def _build_turnout_sections(self) -> list[TurnoutSection]:
193        return [
194            TurnoutSection(
195                manager_class=turnout_mgr_class_for_prefix(prefix),
196                rows=rows,
197            )
198            for prefix, rows in _group_by_prefix(self._turnouts.values())
199        ]
200
201    def _build_sensor_sections(self) -> list[SensorSection]:
202        return [
203            SensorSection(
204                manager_class=sensor_mgr_class_for_prefix(prefix),
205                rows=rows,
206            )
207            for prefix, rows in _group_by_prefix(self._sensors.values())
208        ]
209
210    def _build_light_sections(self) -> list[LightSection]:
211        return [
212            LightSection(
213                manager_class=light_mgr_class_for_prefix(prefix),
214                rows=rows,
215            )
216            for prefix, rows in _group_by_prefix(self._lights.values())
217        ]
218
219    def _flush(self) -> None:
220        """Atomically rewrite the file. Failures are logged, not raised."""
221        path = self.profile.config_file
222        try:
223            data = save_config_bytes(self._build_config())
224            tmp = path.with_suffix(path.suffix + ".tmp")
225            tmp.write_bytes(data)
226            os.replace(tmp, path)
227        except OSError as e:
228            _log.warning("ConfigStore: failed to write %s: %s", path, e)
229
230    # ----- introspection ----------------------------------------------
231
232    def __len__(self) -> int:
233        return len(self._turnouts) + len(self._sensors) + len(self._lights)
234
235    def path(self) -> pathlib.Path:
236        return self.profile.config_file
@dataclass
class ConfigStore:
 79@dataclass
 80class ConfigStore:
 81    """In-memory + on-disk Configuration.xml."""
 82
 83    profile: Profile
 84    _turnouts: dict[str, TurnoutRow] = field(default_factory=dict)
 85    _sensors: dict[str, SensorRow] = field(default_factory=dict)
 86    _lights: dict[str, LightRow] = field(default_factory=dict)
 87    _preserved_doctype: str | None = None
 88    _preserved_root_attribs: dict[str, str] = field(default_factory=dict)
 89    _preserved_elements: list = field(default_factory=list)
 90
 91    def __post_init__(self) -> None:
 92        self.profile.ensure()
 93        self._load_existing()
 94
 95    def _load_existing(self) -> None:
 96        """Load the on-disk Configuration.xml into memory, if it exists."""
 97        path = self.profile.config_file
 98        if not path.exists():
 99            cfg = empty_config()
100            self._preserved_doctype = cfg.doctype
101            return
102        try:
103            cfg = load_config(path)
104        except Exception as e:  # noqa: BLE001
105            _log.warning(
106                "ConfigStore: failed to parse %s (%s); starting empty", path, e
107            )
108            self._preserved_doctype = empty_config().doctype
109            return
110        for row in cfg.all_turnouts():
111            self._turnouts[row.system_name] = row
112        for row in cfg.all_sensors():
113            self._sensors[row.system_name] = row
114        for row in cfg.all_lights():
115            self._lights[row.system_name] = row
116        self._preserved_doctype = cfg.doctype
117        self._preserved_root_attribs = dict(cfg.root_attribs)
118        self._preserved_elements = list(cfg.preserved)
119
120    # ----- read --------------------------------------------------------
121
122    def turnouts(self) -> Iterable[TurnoutRow]:
123        return self._turnouts.values()
124
125    def sensors(self) -> Iterable[SensorRow]:
126        return self._sensors.values()
127
128    def lights(self) -> Iterable[LightRow]:
129        return self._lights.values()
130
131    # ----- write -------------------------------------------------------
132
133    def upsert_turnout(self, row: TurnoutRow) -> None:
134        # Preserve unmodeled extras (locked / lockMode / decoder etc.)
135        # from any existing row — the bean services don't carry these
136        # fields and shouldn't accidentally erase them on edit.
137        prev = self._turnouts.get(row.system_name)
138        if prev and not row.extra_attribs and prev.extra_attribs:
139            row.extra_attribs = dict(prev.extra_attribs)
140        if prev and row.comment is None and prev.comment:
141            row.comment = prev.comment
142        self._turnouts[row.system_name] = row
143        self._flush()
144
145    def upsert_sensor(self, row: SensorRow) -> None:
146        prev = self._sensors.get(row.system_name)
147        if prev and not row.extra_attribs and prev.extra_attribs:
148            row.extra_attribs = dict(prev.extra_attribs)
149        if prev and row.comment is None and prev.comment:
150            row.comment = prev.comment
151        self._sensors[row.system_name] = row
152        self._flush()
153
154    def upsert_light(self, row: LightRow) -> None:
155        prev = self._lights.get(row.system_name)
156        if prev and not row.extra_attribs and prev.extra_attribs:
157            row.extra_attribs = dict(prev.extra_attribs)
158        if prev and row.comment is None and prev.comment:
159            row.comment = prev.comment
160        self._lights[row.system_name] = row
161        self._flush()
162
163    def remove_turnout(self, system_name: str) -> bool:
164        existed = self._turnouts.pop(system_name, None) is not None
165        if existed:
166            self._flush()
167        return existed
168
169    def remove_sensor(self, system_name: str) -> bool:
170        existed = self._sensors.pop(system_name, None) is not None
171        if existed:
172            self._flush()
173        return existed
174
175    def remove_light(self, system_name: str) -> bool:
176        existed = self._lights.pop(system_name, None) is not None
177        if existed:
178            self._flush()
179        return existed
180
181    # ----- I/O ---------------------------------------------------------
182
183    def _build_config(self) -> ConfigFile:
184        return ConfigFile(
185            root_attribs=dict(self._preserved_root_attribs),
186            doctype=self._preserved_doctype,
187            turnout_sections=self._build_turnout_sections(),
188            sensor_sections=self._build_sensor_sections(),
189            light_sections=self._build_light_sections(),
190            preserved=list(self._preserved_elements),
191        )
192
193    def _build_turnout_sections(self) -> list[TurnoutSection]:
194        return [
195            TurnoutSection(
196                manager_class=turnout_mgr_class_for_prefix(prefix),
197                rows=rows,
198            )
199            for prefix, rows in _group_by_prefix(self._turnouts.values())
200        ]
201
202    def _build_sensor_sections(self) -> list[SensorSection]:
203        return [
204            SensorSection(
205                manager_class=sensor_mgr_class_for_prefix(prefix),
206                rows=rows,
207            )
208            for prefix, rows in _group_by_prefix(self._sensors.values())
209        ]
210
211    def _build_light_sections(self) -> list[LightSection]:
212        return [
213            LightSection(
214                manager_class=light_mgr_class_for_prefix(prefix),
215                rows=rows,
216            )
217            for prefix, rows in _group_by_prefix(self._lights.values())
218        ]
219
220    def _flush(self) -> None:
221        """Atomically rewrite the file. Failures are logged, not raised."""
222        path = self.profile.config_file
223        try:
224            data = save_config_bytes(self._build_config())
225            tmp = path.with_suffix(path.suffix + ".tmp")
226            tmp.write_bytes(data)
227            os.replace(tmp, path)
228        except OSError as e:
229            _log.warning("ConfigStore: failed to write %s: %s", path, e)
230
231    # ----- introspection ----------------------------------------------
232
233    def __len__(self) -> int:
234        return len(self._turnouts) + len(self._sensors) + len(self._lights)
235
236    def path(self) -> pathlib.Path:
237        return self.profile.config_file

In-memory + on-disk Configuration.xml.

ConfigStore( profile: jmri_core.persistence.Profile, _turnouts: dict[str, jmri_core.persistence.TurnoutRow] = <factory>, _sensors: dict[str, jmri_core.persistence.SensorRow] = <factory>, _lights: dict[str, jmri_core.persistence.LightRow] = <factory>, _preserved_doctype: str | None = None, _preserved_root_attribs: dict[str, str] = <factory>, _preserved_elements: list = <factory>)
def turnouts(self) -> Iterable[jmri_core.persistence.TurnoutRow]:
122    def turnouts(self) -> Iterable[TurnoutRow]:
123        return self._turnouts.values()
def sensors(self) -> Iterable[jmri_core.persistence.SensorRow]:
125    def sensors(self) -> Iterable[SensorRow]:
126        return self._sensors.values()
def lights(self) -> Iterable[jmri_core.persistence.LightRow]:
128    def lights(self) -> Iterable[LightRow]:
129        return self._lights.values()
def upsert_turnout(self, row: jmri_core.persistence.TurnoutRow) -> None:
133    def upsert_turnout(self, row: TurnoutRow) -> None:
134        # Preserve unmodeled extras (locked / lockMode / decoder etc.)
135        # from any existing row — the bean services don't carry these
136        # fields and shouldn't accidentally erase them on edit.
137        prev = self._turnouts.get(row.system_name)
138        if prev and not row.extra_attribs and prev.extra_attribs:
139            row.extra_attribs = dict(prev.extra_attribs)
140        if prev and row.comment is None and prev.comment:
141            row.comment = prev.comment
142        self._turnouts[row.system_name] = row
143        self._flush()
def upsert_sensor(self, row: jmri_core.persistence.SensorRow) -> None:
145    def upsert_sensor(self, row: SensorRow) -> None:
146        prev = self._sensors.get(row.system_name)
147        if prev and not row.extra_attribs and prev.extra_attribs:
148            row.extra_attribs = dict(prev.extra_attribs)
149        if prev and row.comment is None and prev.comment:
150            row.comment = prev.comment
151        self._sensors[row.system_name] = row
152        self._flush()
def upsert_light(self, row: jmri_core.persistence.LightRow) -> None:
154    def upsert_light(self, row: LightRow) -> None:
155        prev = self._lights.get(row.system_name)
156        if prev and not row.extra_attribs and prev.extra_attribs:
157            row.extra_attribs = dict(prev.extra_attribs)
158        if prev and row.comment is None and prev.comment:
159            row.comment = prev.comment
160        self._lights[row.system_name] = row
161        self._flush()
def remove_turnout(self, system_name: str) -> bool:
163    def remove_turnout(self, system_name: str) -> bool:
164        existed = self._turnouts.pop(system_name, None) is not None
165        if existed:
166            self._flush()
167        return existed
def remove_sensor(self, system_name: str) -> bool:
169    def remove_sensor(self, system_name: str) -> bool:
170        existed = self._sensors.pop(system_name, None) is not None
171        if existed:
172            self._flush()
173        return existed
def remove_light(self, system_name: str) -> bool:
175    def remove_light(self, system_name: str) -> bool:
176        existed = self._lights.pop(system_name, None) is not None
177        if existed:
178            self._flush()
179        return existed
def path(self) -> pathlib._local.Path:
236    def path(self) -> pathlib.Path:
237        return self.profile.config_file