jmri_server.services.panel
PanelService — JSON topic panel.
Lists JMRI panel XML files under the active profile's panels/ dir.
Wire shape per jmri/server/json/util/panel-server.json:
name— reference name (<Type>/<basename>, matching JMRI's URL convention).userName— display name (from the XML file'suserNameattribute if present, else the basename).URL— server-relative path to fetch the panel XML.type— one of"Panel" | "Control Panel" | "Layout" | "Switchboard", guessed from the root XML element.
This is a listing-only service in this pass — no serving of the
panel XML content, no editing. Real panel rendering lives with Panel
Editor (Tier 1 #3). What we ship here is enough for mobile clients
that probe the panel topic on connect to see valid entries and
stop 404-ing on the topic itself.
1"""``PanelService`` — JSON topic ``panel``. 2 3Lists JMRI panel XML files under the active profile's ``panels/`` dir. 4Wire shape per ``jmri/server/json/util/panel-server.json``: 5 6* ``name`` — reference name (``<Type>/<basename>``, matching JMRI's URL 7 convention). 8* ``userName`` — display name (from the XML file's ``userName`` 9 attribute if present, else the basename). 10* ``URL`` — server-relative path to fetch the panel XML. 11* ``type`` — one of ``"Panel" | "Control Panel" | "Layout" | 12 "Switchboard"``, guessed from the root XML element. 13 14This is a **listing-only** service in this pass — no serving of the 15panel XML content, no editing. Real panel rendering lives with Panel 16Editor (Tier 1 #3). What we ship here is enough for mobile clients 17that probe the ``panel`` topic on connect to see valid entries and 18stop 404-ing on the topic itself. 19""" 20from __future__ import annotations 21 22import pathlib 23import xml.etree.ElementTree as ET 24from typing import Any 25 26from jmri_server.service import JsonService, JsonServiceError 27 28 29# Map JMRI panel-XML root elements to the schema's ``type`` enum. 30_TYPE_BY_ROOT = { 31 "LayoutEditor": "Layout", 32 "ControlPanelEditor": "Control Panel", 33 "PanelEditor": "Panel", 34 "SwitchboardEditor": "Switchboard", 35} 36 37 38def _panels_dir(ctx: Any) -> pathlib.Path | None: 39 """Locate the active profile's panels dir, if any. 40 41 Falls back through: 42 * ``ctx._panels_dir`` (test hook — lets integration tests point at 43 a temp directory); 44 * ``ctx.config_store.profile.panels_dir`` (production path once 45 the ConfigStore's profile is attached); 46 * ``Profile.default().panels_dir`` (last-resort default). 47 48 Returns ``None`` if none of those resolve to an existing directory 49 — callers treat that as "no panels" rather than an error. 50 """ 51 override = getattr(ctx, "_panels_dir", None) 52 if isinstance(override, (str, pathlib.Path)): 53 p = pathlib.Path(override) 54 if p.is_dir(): 55 return p 56 57 store = getattr(ctx, "config_store", None) 58 profile = getattr(store, "profile", None) if store is not None else None 59 if profile is not None: 60 panels = getattr(profile, "panels_dir", None) 61 if isinstance(panels, pathlib.Path) and panels.is_dir(): 62 return panels 63 64 try: 65 from jmri_core.persistence.profile import Profile # noqa: PLC0415 66 except ImportError: 67 return None 68 default = getattr(Profile.default(), "panels_dir", None) 69 if isinstance(default, pathlib.Path) and default.is_dir(): 70 return default 71 return None 72 73 74def _guess_panel_type(path: pathlib.Path) -> str | None: 75 """Peek at the root element of a panel XML file. Returns one of the 76 four schema types, or ``None`` if the root isn't a recognized 77 panel-editor root.""" 78 try: 79 # ``iterparse`` doesn't need the whole file — we bail after the 80 # first "start" event. 81 for _event, elem in ET.iterparse(path, events=("start",)): 82 return _TYPE_BY_ROOT.get(elem.tag) 83 except (ET.ParseError, OSError): 84 return None 85 return None 86 87 88def _project(path: pathlib.Path) -> dict[str, Any] | None: 89 """Build the wire form for one panel XML file, or ``None`` if the 90 file isn't a recognized panel.""" 91 ptype = _guess_panel_type(path) 92 if ptype is None: 93 return None 94 # ``name`` matches the URL: ``<Type>/<basename>`` — JMRI uses this 95 # both as an id and as the fetch path. 96 stem = path.stem 97 short_type = ptype.replace(" ", "") 98 reference = f"{short_type}/{stem}" 99 return { 100 "name": reference, 101 "userName": stem, 102 "URL": f"/panel/{reference}?format=xml", 103 "type": ptype, 104 } 105 106 107class PanelService(JsonService): 108 """List panels on disk under the active profile. 109 110 Supports ``list`` and ``get`` (by ``name``). No ``put`` / ``post`` 111 / ``delete`` — panel authoring goes through Panel Editor, not this 112 service. 113 """ 114 115 topic = "panel" 116 117 async def on_list( 118 self, data: dict[str, Any] | None, 119 ) -> list[dict[str, Any]]: 120 return list(self._all_panels()) 121 122 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 123 data = data or {} 124 name = data.get("name") 125 if not isinstance(name, str) or not name: 126 raise JsonServiceError(400, "panel.get requires 'name'") 127 for panel in self._all_panels(): 128 if panel["name"] == name: 129 return panel 130 raise JsonServiceError(404, f"no panel named {name!r}") 131 132 def _all_panels(self) -> list[dict[str, Any]]: 133 panels_dir = _panels_dir(self.ctx) 134 if panels_dir is None: 135 return [] 136 out: list[dict[str, Any]] = [] 137 for xml_path in sorted(panels_dir.glob("*.xml")): 138 projection = _project(xml_path) 139 if projection is not None: 140 out.append(projection) 141 return out
108class PanelService(JsonService): 109 """List panels on disk under the active profile. 110 111 Supports ``list`` and ``get`` (by ``name``). No ``put`` / ``post`` 112 / ``delete`` — panel authoring goes through Panel Editor, not this 113 service. 114 """ 115 116 topic = "panel" 117 118 async def on_list( 119 self, data: dict[str, Any] | None, 120 ) -> list[dict[str, Any]]: 121 return list(self._all_panels()) 122 123 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 124 data = data or {} 125 name = data.get("name") 126 if not isinstance(name, str) or not name: 127 raise JsonServiceError(400, "panel.get requires 'name'") 128 for panel in self._all_panels(): 129 if panel["name"] == name: 130 return panel 131 raise JsonServiceError(404, f"no panel named {name!r}") 132 133 def _all_panels(self) -> list[dict[str, Any]]: 134 panels_dir = _panels_dir(self.ctx) 135 if panels_dir is None: 136 return [] 137 out: list[dict[str, Any]] = [] 138 for xml_path in sorted(panels_dir.glob("*.xml")): 139 projection = _project(xml_path) 140 if projection is not None: 141 out.append(projection) 142 return out
List panels on disk under the active profile.
Supports list and get (by name). No put / post
/ delete — panel authoring goes through Panel Editor, not this
service.
async def
on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
123 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 124 data = data or {} 125 name = data.get("name") 126 if not isinstance(name, str) or not name: 127 raise JsonServiceError(400, "panel.get requires 'name'") 128 for panel in self._all_panels(): 129 if panel["name"] == name: 130 return panel 131 raise JsonServiceError(404, f"no panel named {name!r}")