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}
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.
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}