jmri_server.services.roster

Roster service — full CRUD over the server-owned RosterStore.

Wire shape per contracts/json-schema/roster/rosterEntry-server.json: each entry is a dict with the per-locomotive fields (id, dccAddress, roadName, decoderFamily, decoderModel, …).

Methods:

  • method=list → list of roster entries.
  • method=get, data={"id": ...} → one entry.
  • method=put, data={...fields...} → create-or-replace.
  • method=post, data={...fields...} → update.
  • method=delete, data={"id": ...} → remove.
  1"""Roster service — full CRUD over the server-owned :class:`RosterStore`.
  2
  3Wire shape per ``contracts/json-schema/roster/rosterEntry-server.json``:
  4each entry is a dict with the per-locomotive fields (id, dccAddress,
  5roadName, decoderFamily, decoderModel, …).
  6
  7Methods:
  8
  9* ``method=list`` → list of roster entries.
 10* ``method=get``, ``data={"id": ...}`` → one entry.
 11* ``method=put``, ``data={...fields...}`` → create-or-replace.
 12* ``method=post``, ``data={...fields...}`` → update.
 13* ``method=delete``, ``data={"id": ...}`` → remove.
 14"""
 15from __future__ import annotations
 16
 17from typing import Any
 18
 19from jmri_core.beans.roster_entry import RosterEntry
 20
 21from jmri_server.roster_media import media_url_for, resolve_media_path
 22from jmri_server.service import JsonService, JsonServiceError
 23
 24
 25def _entry_to_json(
 26    e: RosterEntry, *, profile: Any | None = None,
 27) -> dict[str, Any]:
 28    """Project a RosterEntry to the JMRI-style camelCase wire shape.
 29
 30    ``profile`` (optional) enables the ``imageUrl`` / ``iconUrl``
 31    extension: when the entry's ``image_file_path`` /
 32    ``icon_file_path`` resolves to a real file under the profile,
 33    the corresponding URL is included. Legacy callers that pass no
 34    profile get the same shape they always did — the extension is
 35    opt-in per call to avoid touching the filesystem for every list
 36    render on hosts without a profile."""
 37    out = {
 38        "id": e.id,
 39        "fileName": e.file_name,
 40        "dccAddress": e.dcc_address,
 41        "longAddress": e.long_address,
 42        "protocol": e.protocol,
 43        "decoderFamily": e.decoder_family,
 44        "decoderModel": e.decoder_model,
 45        "roadName": e.road_name,
 46        "roadNumber": e.road_number,
 47        "mfg": e.mfg,
 48        "owner": e.owner,
 49        "model": e.model,
 50        "comment": e.comment,
 51        "maxSpeedPct": e.max_speed_pct,
 52        "imageFilePath": e.image_file_path,
 53        "iconFilePath": e.icon_file_path,
 54        "url": e.url,
 55        "attributes": e.attributes,
 56    }
 57    if profile is not None:
 58        if resolve_media_path(profile, e.image_file_path) is not None:
 59            out["imageUrl"] = media_url_for(e, "image")
 60        if resolve_media_path(profile, e.icon_file_path) is not None:
 61            out["iconUrl"] = media_url_for(e, "icon")
 62    return out
 63
 64
 65def _json_to_entry(data: dict[str, Any], *, base: RosterEntry | None = None) -> RosterEntry:
 66    """Build (or update) a RosterEntry from the JSON shape."""
 67    fields = {
 68        "id": data.get("id") or (base.id if base else ""),
 69        "file_name": data.get("fileName") or (base.file_name if base else ""),
 70        "dcc_address": data.get("dccAddress") or (base.dcc_address if base else "3"),
 71        "long_address": data.get("longAddress", base.long_address if base else False),
 72        "protocol": data.get("protocol") or (base.protocol if base else "dcc_short"),
 73        "decoder_family": data.get("decoderFamily") or (base.decoder_family if base else ""),
 74        "decoder_model": data.get("decoderModel") or (base.decoder_model if base else ""),
 75        "road_name": data.get("roadName") or (base.road_name if base else ""),
 76        "road_number": data.get("roadNumber") or (base.road_number if base else ""),
 77        "mfg": data.get("mfg") or (base.mfg if base else ""),
 78        "owner": data.get("owner") or (base.owner if base else ""),
 79        "model": data.get("model") or (base.model if base else ""),
 80        "comment": data.get("comment") or (base.comment if base else ""),
 81        "max_speed_pct": int(data.get("maxSpeedPct", base.max_speed_pct if base else 100)),
 82        "image_file_path": data.get("imageFilePath") or (base.image_file_path if base else None),
 83        "icon_file_path": data.get("iconFilePath") or (base.icon_file_path if base else None),
 84        "url": data.get("url") or (base.url if base else None),
 85        "attributes": data.get("attributes") or (base.attributes if base else {}),
 86    }
 87    return RosterEntry(**fields)
 88
 89
 90def _lazy_roster_store(ctx: Any) -> Any | None:
 91    """Return the shared :class:`RosterStore` for *ctx*, constructing
 92    it on first access. Returns ``None`` when no profile can be
 93    resolved (bare-context tests) — callers should treat that as
 94    "no roster available"."""
 95    store = getattr(ctx, "_roster_store", None)
 96    if store is not None:
 97        return store
 98    try:
 99        from jmri_core.persistence.profile import Profile  # noqa: PLC0415
100
101        from jmri_server.roster_store import RosterStore  # noqa: PLC0415
102    except ImportError:
103        return None
104    profile = getattr(ctx, "_roster_profile", None) or Profile.default()
105    try:
106        store = RosterStore(profile=profile)
107    except Exception:  # noqa: BLE001
108        return None
109    ctx._roster_store = store
110    return store
111
112
113def _roster_service_get_sync(ctx: Any, loco_id: str) -> dict[str, Any] | None:
114    """Sync helper for callers outside the async service dispatch path.
115
116    Used by :func:`jmri_server.services.programmer._loco_cv_store` to
117    resolve a loco's decoder family/model without spinning an event
118    loop — the RosterStore's read path is sync-safe.
119
120    Returns the JSON-shaped entry dict or ``None`` if the store isn't
121    initialised / the loco isn't in it.
122    """
123    store = _lazy_roster_store(ctx)
124    if store is None:
125        return None
126    entry = store.get(loco_id)
127    if entry is None:
128        return None
129    return _entry_to_json(entry)
130
131
132class RosterService(JsonService):
133    """Full-CRUD roster service over the :class:`RosterStore` on the context."""
134
135    topic = "roster"
136
137    @property
138    def _store(self) -> Any:
139        """Return the active RosterStore. Lazily constructed on first
140        use via :func:`_lazy_roster_store`. Raises if no profile can
141        be resolved — the service can't function without one, unlike
142        the roster-media routes which just 404."""
143        store = _lazy_roster_store(self.ctx)
144        if store is None:
145            raise RuntimeError("no roster store on the context")
146        return store
147
148    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
149        profile = self._store.profile
150        return [_entry_to_json(e, profile=profile) for e in self._store]
151
152    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
153        data = data or {}
154        entry_id = data.get("id")
155        if not isinstance(entry_id, str):
156            raise JsonServiceError(400, "roster.get requires 'id'")
157        entry = self._store.get(entry_id)
158        if entry is None:
159            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
160        return _entry_to_json(entry, profile=self._store.profile)
161
162    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
163        data = data or {}
164        entry_id = data.get("id")
165        if not isinstance(entry_id, str) or not entry_id:
166            raise JsonServiceError(400, "roster.put requires a non-empty 'id'")
167        existing = self._store.get(entry_id)
168        entry = _json_to_entry(data, base=existing)
169        return _entry_to_json(self._store.put(entry), profile=self._store.profile)
170
171    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
172        data = data or {}
173        entry_id = data.get("id")
174        if not isinstance(entry_id, str):
175            raise JsonServiceError(400, "roster.post requires 'id'")
176        existing = self._store.get(entry_id)
177        if existing is None:
178            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
179        entry = _json_to_entry(data, base=existing)
180        return _entry_to_json(self._store.put(entry), profile=self._store.profile)
181
182    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
183        data = data or {}
184        entry_id = data.get("id")
185        if not isinstance(entry_id, str):
186            raise JsonServiceError(400, "roster.delete requires 'id'")
187        if not self._store.delete(entry_id):
188            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
189        return {"id": entry_id}
class RosterService(jmri_server.service.JsonService):
133class RosterService(JsonService):
134    """Full-CRUD roster service over the :class:`RosterStore` on the context."""
135
136    topic = "roster"
137
138    @property
139    def _store(self) -> Any:
140        """Return the active RosterStore. Lazily constructed on first
141        use via :func:`_lazy_roster_store`. Raises if no profile can
142        be resolved — the service can't function without one, unlike
143        the roster-media routes which just 404."""
144        store = _lazy_roster_store(self.ctx)
145        if store is None:
146            raise RuntimeError("no roster store on the context")
147        return store
148
149    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
150        profile = self._store.profile
151        return [_entry_to_json(e, profile=profile) for e in self._store]
152
153    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
154        data = data or {}
155        entry_id = data.get("id")
156        if not isinstance(entry_id, str):
157            raise JsonServiceError(400, "roster.get requires 'id'")
158        entry = self._store.get(entry_id)
159        if entry is None:
160            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
161        return _entry_to_json(entry, profile=self._store.profile)
162
163    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
164        data = data or {}
165        entry_id = data.get("id")
166        if not isinstance(entry_id, str) or not entry_id:
167            raise JsonServiceError(400, "roster.put requires a non-empty 'id'")
168        existing = self._store.get(entry_id)
169        entry = _json_to_entry(data, base=existing)
170        return _entry_to_json(self._store.put(entry), profile=self._store.profile)
171
172    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
173        data = data or {}
174        entry_id = data.get("id")
175        if not isinstance(entry_id, str):
176            raise JsonServiceError(400, "roster.post requires 'id'")
177        existing = self._store.get(entry_id)
178        if existing is None:
179            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
180        entry = _json_to_entry(data, base=existing)
181        return _entry_to_json(self._store.put(entry), profile=self._store.profile)
182
183    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
184        data = data or {}
185        entry_id = data.get("id")
186        if not isinstance(entry_id, str):
187            raise JsonServiceError(400, "roster.delete requires 'id'")
188        if not self._store.delete(entry_id):
189            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
190        return {"id": entry_id}

Full-CRUD roster service over the RosterStore on the context.

topic = 'roster'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
149    async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]:
150        profile = self._store.profile
151        return [_entry_to_json(e, profile=profile) for e in self._store]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
153    async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]:
154        data = data or {}
155        entry_id = data.get("id")
156        if not isinstance(entry_id, str):
157            raise JsonServiceError(400, "roster.get requires 'id'")
158        entry = self._store.get(entry_id)
159        if entry is None:
160            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
161        return _entry_to_json(entry, profile=self._store.profile)
async def on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
163    async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]:
164        data = data or {}
165        entry_id = data.get("id")
166        if not isinstance(entry_id, str) or not entry_id:
167            raise JsonServiceError(400, "roster.put requires a non-empty 'id'")
168        existing = self._store.get(entry_id)
169        entry = _json_to_entry(data, base=existing)
170        return _entry_to_json(self._store.put(entry), profile=self._store.profile)
async def on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
172    async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]:
173        data = data or {}
174        entry_id = data.get("id")
175        if not isinstance(entry_id, str):
176            raise JsonServiceError(400, "roster.post requires 'id'")
177        existing = self._store.get(entry_id)
178        if existing is None:
179            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
180        entry = _json_to_entry(data, base=existing)
181        return _entry_to_json(self._store.put(entry), profile=self._store.profile)
async def on_delete(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
183    async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]:
184        data = data or {}
185        entry_id = data.get("id")
186        if not isinstance(entry_id, str):
187            raise JsonServiceError(400, "roster.delete requires 'id'")
188        if not self._store.delete(entry_id):
189            raise JsonServiceError(404, f"no roster entry with id {entry_id!r}")
190        return {"id": entry_id}