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's userName attribute 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
class PanelService(jmri_server.service.JsonService):
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.

topic = 'panel'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
118    async def on_list(
119        self, data: dict[str, Any] | None,
120    ) -> list[dict[str, Any]]:
121        return list(self._all_panels())
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}")