jmri_server.services.consist
ConsistService — JSON wire interface for the consist manager.
Wire shape (a jmri-server-specific extension; stock JMRI Java's JSON
API exposes consist too, but with a different envelope — this
service uses the same envelope style as throttle and scripts
so all rewrite-specific services are consistent).
Methods:
list→ array of projected consist dicts.get,data={"address": N}→ one projected dict.put,data={"address": N, "long_address": bool, "kind": "software"|"command_station"|"advanced", "comment": "..."}→ create-if-absent and return the projected dict. Idempotent.post,data={"address": N, "action": ..., ...}— mutate the consist. Verbs:add_member—member={address, long_address, direction_normal}.remove_member—member_address= int.set_speed—speed= float in [-1.0, 1.0].set_direction—direction="forward"/"reverse".set_function—function= int 0..28,on= bool.emergency_stop— no extra fields.
delete,data={"address": N}→ drop the consist.
Projected consist shape::
{
"address": 3601,
"long_address": true,
"kind": "command_station",
"comment": "Baldwin twins",
"locos": [
{"address": 3601, "long_address": true,
"direction_normal": true, "position": 0},
{"address": 3602, "long_address": true,
"direction_normal": false, "position": 1}
]
}
1"""``ConsistService`` — JSON wire interface for the consist manager. 2 3Wire shape (a jmri-server-specific extension; stock JMRI Java's JSON 4API exposes ``consist`` too, but with a different envelope — this 5service uses the same envelope style as ``throttle`` and ``scripts`` 6so all rewrite-specific services are consistent). 7 8Methods: 9 10* ``list`` → array of projected consist dicts. 11* ``get``, ``data={"address": N}`` → one projected dict. 12* ``put``, ``data={"address": N, "long_address": bool, "kind": 13 "software"|"command_station"|"advanced", "comment": "..."}`` → 14 create-if-absent and return the projected dict. Idempotent. 15* ``post``, ``data={"address": N, "action": ..., ...}`` — mutate the 16 consist. Verbs: 17 - ``add_member`` — ``member`` = ``{address, long_address, 18 direction_normal}``. 19 - ``remove_member`` — ``member_address`` = int. 20 - ``set_speed`` — ``speed`` = float in [-1.0, 1.0]. 21 - ``set_direction`` — ``direction`` = ``"forward"``/``"reverse"``. 22 - ``set_function`` — ``function`` = int 0..28, ``on`` = bool. 23 - ``emergency_stop`` — no extra fields. 24* ``delete``, ``data={"address": N}`` → drop the consist. 25 26Projected consist shape:: 27 28 { 29 "address": 3601, 30 "long_address": true, 31 "kind": "command_station", 32 "comment": "Baldwin twins", 33 "locos": [ 34 {"address": 3601, "long_address": true, 35 "direction_normal": true, "position": 0}, 36 {"address": 3602, "long_address": true, 37 "direction_normal": false, "position": 1} 38 ] 39 } 40""" 41from __future__ import annotations 42 43from typing import Any 44 45from jmri_core.beans.consist import CONSIST_ADDRESS_MAX, Consist 46from jmri_core.beans.consist_manager import ConsistManager 47from jmri_core.state import ThrottleDirection 48 49from jmri_server.service import JsonService, JsonServiceError 50 51 52def _project(c: Consist, mgr: ConsistManager | None = None) -> dict[str, Any]: 53 """Project a consist to its wire form. 54 55 ``mgr`` is optional so callers that only have a :class:`Consist` in 56 hand (mostly tests and older code paths) can still project — the 57 speed/direction fields fall back to defaults in that case. The 58 push wiring and the JSON service pass the manager so the projection 59 carries the runtime drive state the UI needs to render the slider 60 at the right position and highlight the active direction button. 61 """ 62 projection: dict[str, Any] = { 63 "address": c.address, 64 "kind": c.kind, 65 "comment": c.comment, 66 "locos": [ 67 { 68 "address": m.address, 69 "long_address": m.long_address, 70 "direction_normal": m.direction_normal, 71 "position": m.position, 72 } 73 for m in c.locos 74 ], 75 } 76 if mgr is not None: 77 projection["speed"] = mgr.current_speed(c.address) 78 projection["direction"] = ( 79 "forward" 80 if mgr.current_direction(c.address) is ThrottleDirection.FORWARD 81 else "reverse" 82 ) 83 else: 84 projection["speed"] = 0.0 85 projection["direction"] = "forward" 86 return projection 87 88 89def _address_from(data: dict[str, Any]) -> int: 90 """Extract the consist address from a request. Raises 400 on missing.""" 91 addr = data.get("address") 92 if not isinstance(addr, int): 93 raise JsonServiceError(400, "consist request requires int 'address'") 94 return addr 95 96 97class ConsistService(JsonService): 98 topic = "consist" 99 100 @property 101 def _consists(self) -> ConsistManager: 102 return self.ctx.connection.consists 103 104 def _require(self, address: int) -> Consist: 105 c = self._consists.get(address) 106 if c is None: 107 raise JsonServiceError(404, f"no consist at address {address}") 108 return c 109 110 async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]: 111 return [_project(c, self._consists) for c in self._consists] 112 113 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 114 addr = _address_from(data or {}) 115 return _project(self._require(addr), self._consists) 116 117 async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]: 118 """Create-if-absent. Idempotent: returns the existing consist if 119 one already lives at ``address`` without overwriting its 120 members. Change comment/kind on an existing consist via a 121 follow-up ``post`` (not currently wired — the two fields are 122 immutable after create; a delete+recreate is the escape hatch). 123 124 Enforces two rules that were left implicit before: 125 126 * Consist address must be 1..127 (short only). CV19 is 7-bit, 127 so anything else is either meaningless (advanced) or 128 gratuitously inconsistent across kinds. 129 * Address must not collide with any existing roster loco. If 130 it did, the roster loco would silently participate in every 131 fan-out addressed to that consist — a footgun. 132 """ 133 data = data or {} 134 addr = _address_from(data) 135 if not 1 <= addr <= CONSIST_ADDRESS_MAX: 136 raise JsonServiceError( 137 400, 138 f"consist address must be 1..{CONSIST_ADDRESS_MAX} short, " 139 f"got {addr}", 140 ) 141 kind = data.get("kind") 142 comment = str(data.get("comment", "")) 143 # Validate kind against the model's Literal — raise 400 on typo. 144 if kind is not None and kind not in ("software", "command_station", "advanced"): 145 raise JsonServiceError(400, f"invalid consist kind: {kind!r}") 146 # Roster-collision check. Skip if the consist already exists 147 # at this address (idempotent create still wins). 148 if self._consists.get(addr) is None: 149 conflict = self._roster_conflict(addr) 150 if conflict is not None: 151 raise JsonServiceError( 152 409, 153 f"consist address {addr} collides with roster loco " 154 f"{conflict!r} (short {addr}); pick a different " 155 f"consist address or remove/renumber the loco.", 156 ) 157 consist = await self._consists.create(addr, kind=kind, comment=comment) 158 return _project(consist, self._consists) 159 160 def _roster_conflict(self, addr: int) -> str | None: 161 """Return the id of any roster entry using *addr* as a short 162 DCC address, or ``None`` if there's no collision. 163 164 Only short addresses count — a long-address loco at the same 165 numeric value shares no wire space with a 1..127 consist. The 166 roster store is lazy-loaded here on first use (same pattern 167 as :class:`RosterService`) so the check works even when the 168 user's first action is Create-consist and no roster/throttle 169 page has been hit yet — that was a real bug where the check 170 silently short-circuited when navigation started on 171 ``/consists``. 172 """ 173 store = self._roster_store() 174 if store is None: 175 return None 176 target = str(addr) 177 for entry in store: 178 if entry.long_address: 179 continue 180 if entry.dcc_address == target: 181 return entry.id 182 return None 183 184 def _roster_store(self) -> Any: 185 """Return the shared :class:`RosterStore`, constructing it on 186 first access if nothing else has touched it yet. 187 188 Uses the same context attribute (``_roster_store``) as 189 :class:`RosterService` so both see the same instance — no 190 double-load, and edits made through /roster show up in this 191 check immediately. 192 """ 193 store = getattr(self.ctx, "_roster_store", None) 194 if store is not None: 195 return store 196 try: 197 from jmri_core.persistence.profile import Profile # noqa: PLC0415 198 from jmri_server.roster_store import RosterStore # noqa: PLC0415 199 except ImportError: 200 return None 201 profile = getattr(self.ctx, "_roster_profile", None) or Profile.default() 202 try: 203 store = RosterStore(profile=profile) 204 except Exception: # noqa: BLE001 205 # No profile on disk, permission error, corrupt index — the 206 # conflict check is a courtesy, not a hard invariant. Bail 207 # rather than fail create. 208 return None 209 self.ctx._roster_store = store # type: ignore[attr-defined] 210 return store 211 212 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 213 data = data or {} 214 addr = _address_from(data) 215 action = data.get("action") 216 # Every action targets an existing consist. 217 self._require(addr) 218 if action == "add_member": 219 m = data.get("member") or {} 220 member_addr = m.get("address") 221 if not isinstance(member_addr, int): 222 raise JsonServiceError( 223 400, "consist.add_member requires 'member.address'", 224 ) 225 await self._consists.add_member( 226 addr, member_addr, 227 long_address=bool(m.get("long_address", False)), 228 direction_normal=bool(m.get("direction_normal", True)), 229 ) 230 elif action == "remove_member": 231 m_addr = data.get("member_address") 232 if not isinstance(m_addr, int): 233 raise JsonServiceError( 234 400, "consist.remove_member requires int 'member_address'", 235 ) 236 if not await self._consists.remove_member(addr, m_addr): 237 raise JsonServiceError( 238 404, f"consist {addr} has no member at {m_addr}", 239 ) 240 elif action == "set_speed": 241 speed = data.get("speed") 242 if not isinstance(speed, (int, float)): 243 raise JsonServiceError(400, "consist.set_speed requires 'speed'") 244 await self._consists.set_speed(addr, float(speed)) 245 elif action == "set_direction": 246 direction = data.get("direction") 247 if direction not in ("forward", "reverse"): 248 raise JsonServiceError( 249 400, "consist.set_direction requires 'forward'|'reverse'", 250 ) 251 await self._consists.set_direction( 252 addr, 253 ThrottleDirection.FORWARD if direction == "forward" 254 else ThrottleDirection.REVERSE, 255 ) 256 elif action == "set_function": 257 fn = data.get("function") 258 on = data.get("on") 259 if not isinstance(fn, int) or not isinstance(on, bool): 260 raise JsonServiceError( 261 400, "consist.set_function requires int 'function' + bool 'on'", 262 ) 263 await self._consists.set_function(addr, fn, on) 264 elif action == "emergency_stop": 265 await self._consists.emergency_stop(addr) 266 else: 267 raise JsonServiceError(400, f"unknown consist action: {action!r}") 268 return _project(self._require(addr), self._consists) 269 270 async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]: 271 data = data or {} 272 addr = _address_from(data) 273 if not await self._consists.delete(addr): 274 raise JsonServiceError(404, f"no consist at address {addr}") 275 return {"address": addr, "deleted": True}
98class ConsistService(JsonService): 99 topic = "consist" 100 101 @property 102 def _consists(self) -> ConsistManager: 103 return self.ctx.connection.consists 104 105 def _require(self, address: int) -> Consist: 106 c = self._consists.get(address) 107 if c is None: 108 raise JsonServiceError(404, f"no consist at address {address}") 109 return c 110 111 async def on_list(self, data: dict[str, Any] | None) -> list[dict[str, Any]]: 112 return [_project(c, self._consists) for c in self._consists] 113 114 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 115 addr = _address_from(data or {}) 116 return _project(self._require(addr), self._consists) 117 118 async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]: 119 """Create-if-absent. Idempotent: returns the existing consist if 120 one already lives at ``address`` without overwriting its 121 members. Change comment/kind on an existing consist via a 122 follow-up ``post`` (not currently wired — the two fields are 123 immutable after create; a delete+recreate is the escape hatch). 124 125 Enforces two rules that were left implicit before: 126 127 * Consist address must be 1..127 (short only). CV19 is 7-bit, 128 so anything else is either meaningless (advanced) or 129 gratuitously inconsistent across kinds. 130 * Address must not collide with any existing roster loco. If 131 it did, the roster loco would silently participate in every 132 fan-out addressed to that consist — a footgun. 133 """ 134 data = data or {} 135 addr = _address_from(data) 136 if not 1 <= addr <= CONSIST_ADDRESS_MAX: 137 raise JsonServiceError( 138 400, 139 f"consist address must be 1..{CONSIST_ADDRESS_MAX} short, " 140 f"got {addr}", 141 ) 142 kind = data.get("kind") 143 comment = str(data.get("comment", "")) 144 # Validate kind against the model's Literal — raise 400 on typo. 145 if kind is not None and kind not in ("software", "command_station", "advanced"): 146 raise JsonServiceError(400, f"invalid consist kind: {kind!r}") 147 # Roster-collision check. Skip if the consist already exists 148 # at this address (idempotent create still wins). 149 if self._consists.get(addr) is None: 150 conflict = self._roster_conflict(addr) 151 if conflict is not None: 152 raise JsonServiceError( 153 409, 154 f"consist address {addr} collides with roster loco " 155 f"{conflict!r} (short {addr}); pick a different " 156 f"consist address or remove/renumber the loco.", 157 ) 158 consist = await self._consists.create(addr, kind=kind, comment=comment) 159 return _project(consist, self._consists) 160 161 def _roster_conflict(self, addr: int) -> str | None: 162 """Return the id of any roster entry using *addr* as a short 163 DCC address, or ``None`` if there's no collision. 164 165 Only short addresses count — a long-address loco at the same 166 numeric value shares no wire space with a 1..127 consist. The 167 roster store is lazy-loaded here on first use (same pattern 168 as :class:`RosterService`) so the check works even when the 169 user's first action is Create-consist and no roster/throttle 170 page has been hit yet — that was a real bug where the check 171 silently short-circuited when navigation started on 172 ``/consists``. 173 """ 174 store = self._roster_store() 175 if store is None: 176 return None 177 target = str(addr) 178 for entry in store: 179 if entry.long_address: 180 continue 181 if entry.dcc_address == target: 182 return entry.id 183 return None 184 185 def _roster_store(self) -> Any: 186 """Return the shared :class:`RosterStore`, constructing it on 187 first access if nothing else has touched it yet. 188 189 Uses the same context attribute (``_roster_store``) as 190 :class:`RosterService` so both see the same instance — no 191 double-load, and edits made through /roster show up in this 192 check immediately. 193 """ 194 store = getattr(self.ctx, "_roster_store", None) 195 if store is not None: 196 return store 197 try: 198 from jmri_core.persistence.profile import Profile # noqa: PLC0415 199 from jmri_server.roster_store import RosterStore # noqa: PLC0415 200 except ImportError: 201 return None 202 profile = getattr(self.ctx, "_roster_profile", None) or Profile.default() 203 try: 204 store = RosterStore(profile=profile) 205 except Exception: # noqa: BLE001 206 # No profile on disk, permission error, corrupt index — the 207 # conflict check is a courtesy, not a hard invariant. Bail 208 # rather than fail create. 209 return None 210 self.ctx._roster_store = store # type: ignore[attr-defined] 211 return store 212 213 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 214 data = data or {} 215 addr = _address_from(data) 216 action = data.get("action") 217 # Every action targets an existing consist. 218 self._require(addr) 219 if action == "add_member": 220 m = data.get("member") or {} 221 member_addr = m.get("address") 222 if not isinstance(member_addr, int): 223 raise JsonServiceError( 224 400, "consist.add_member requires 'member.address'", 225 ) 226 await self._consists.add_member( 227 addr, member_addr, 228 long_address=bool(m.get("long_address", False)), 229 direction_normal=bool(m.get("direction_normal", True)), 230 ) 231 elif action == "remove_member": 232 m_addr = data.get("member_address") 233 if not isinstance(m_addr, int): 234 raise JsonServiceError( 235 400, "consist.remove_member requires int 'member_address'", 236 ) 237 if not await self._consists.remove_member(addr, m_addr): 238 raise JsonServiceError( 239 404, f"consist {addr} has no member at {m_addr}", 240 ) 241 elif action == "set_speed": 242 speed = data.get("speed") 243 if not isinstance(speed, (int, float)): 244 raise JsonServiceError(400, "consist.set_speed requires 'speed'") 245 await self._consists.set_speed(addr, float(speed)) 246 elif action == "set_direction": 247 direction = data.get("direction") 248 if direction not in ("forward", "reverse"): 249 raise JsonServiceError( 250 400, "consist.set_direction requires 'forward'|'reverse'", 251 ) 252 await self._consists.set_direction( 253 addr, 254 ThrottleDirection.FORWARD if direction == "forward" 255 else ThrottleDirection.REVERSE, 256 ) 257 elif action == "set_function": 258 fn = data.get("function") 259 on = data.get("on") 260 if not isinstance(fn, int) or not isinstance(on, bool): 261 raise JsonServiceError( 262 400, "consist.set_function requires int 'function' + bool 'on'", 263 ) 264 await self._consists.set_function(addr, fn, on) 265 elif action == "emergency_stop": 266 await self._consists.emergency_stop(addr) 267 else: 268 raise JsonServiceError(400, f"unknown consist action: {action!r}") 269 return _project(self._require(addr), self._consists) 270 271 async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]: 272 data = data or {} 273 addr = _address_from(data) 274 if not await self._consists.delete(addr): 275 raise JsonServiceError(404, f"no consist at address {addr}") 276 return {"address": addr, "deleted": True}
Base class for a topic's service handlers.
Subclasses set topic and override the methods they support;
unsupported methods raise 405 by default. Subclasses can also
override REQUIRED_ROLE to change per-method role gates.
118 async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]: 119 """Create-if-absent. Idempotent: returns the existing consist if 120 one already lives at ``address`` without overwriting its 121 members. Change comment/kind on an existing consist via a 122 follow-up ``post`` (not currently wired — the two fields are 123 immutable after create; a delete+recreate is the escape hatch). 124 125 Enforces two rules that were left implicit before: 126 127 * Consist address must be 1..127 (short only). CV19 is 7-bit, 128 so anything else is either meaningless (advanced) or 129 gratuitously inconsistent across kinds. 130 * Address must not collide with any existing roster loco. If 131 it did, the roster loco would silently participate in every 132 fan-out addressed to that consist — a footgun. 133 """ 134 data = data or {} 135 addr = _address_from(data) 136 if not 1 <= addr <= CONSIST_ADDRESS_MAX: 137 raise JsonServiceError( 138 400, 139 f"consist address must be 1..{CONSIST_ADDRESS_MAX} short, " 140 f"got {addr}", 141 ) 142 kind = data.get("kind") 143 comment = str(data.get("comment", "")) 144 # Validate kind against the model's Literal — raise 400 on typo. 145 if kind is not None and kind not in ("software", "command_station", "advanced"): 146 raise JsonServiceError(400, f"invalid consist kind: {kind!r}") 147 # Roster-collision check. Skip if the consist already exists 148 # at this address (idempotent create still wins). 149 if self._consists.get(addr) is None: 150 conflict = self._roster_conflict(addr) 151 if conflict is not None: 152 raise JsonServiceError( 153 409, 154 f"consist address {addr} collides with roster loco " 155 f"{conflict!r} (short {addr}); pick a different " 156 f"consist address or remove/renumber the loco.", 157 ) 158 consist = await self._consists.create(addr, kind=kind, comment=comment) 159 return _project(consist, self._consists)
Create-if-absent. Idempotent: returns the existing consist if
one already lives at address without overwriting its
members. Change comment/kind on an existing consist via a
follow-up post (not currently wired — the two fields are
immutable after create; a delete+recreate is the escape hatch).
Enforces two rules that were left implicit before:
- Consist address must be 1..127 (short only). CV19 is 7-bit, so anything else is either meaningless (advanced) or gratuitously inconsistent across kinds.
- Address must not collide with any existing roster loco. If it did, the roster loco would silently participate in every fan-out addressed to that consist — a footgun.
213 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 214 data = data or {} 215 addr = _address_from(data) 216 action = data.get("action") 217 # Every action targets an existing consist. 218 self._require(addr) 219 if action == "add_member": 220 m = data.get("member") or {} 221 member_addr = m.get("address") 222 if not isinstance(member_addr, int): 223 raise JsonServiceError( 224 400, "consist.add_member requires 'member.address'", 225 ) 226 await self._consists.add_member( 227 addr, member_addr, 228 long_address=bool(m.get("long_address", False)), 229 direction_normal=bool(m.get("direction_normal", True)), 230 ) 231 elif action == "remove_member": 232 m_addr = data.get("member_address") 233 if not isinstance(m_addr, int): 234 raise JsonServiceError( 235 400, "consist.remove_member requires int 'member_address'", 236 ) 237 if not await self._consists.remove_member(addr, m_addr): 238 raise JsonServiceError( 239 404, f"consist {addr} has no member at {m_addr}", 240 ) 241 elif action == "set_speed": 242 speed = data.get("speed") 243 if not isinstance(speed, (int, float)): 244 raise JsonServiceError(400, "consist.set_speed requires 'speed'") 245 await self._consists.set_speed(addr, float(speed)) 246 elif action == "set_direction": 247 direction = data.get("direction") 248 if direction not in ("forward", "reverse"): 249 raise JsonServiceError( 250 400, "consist.set_direction requires 'forward'|'reverse'", 251 ) 252 await self._consists.set_direction( 253 addr, 254 ThrottleDirection.FORWARD if direction == "forward" 255 else ThrottleDirection.REVERSE, 256 ) 257 elif action == "set_function": 258 fn = data.get("function") 259 on = data.get("on") 260 if not isinstance(fn, int) or not isinstance(on, bool): 261 raise JsonServiceError( 262 400, "consist.set_function requires int 'function' + bool 'on'", 263 ) 264 await self._consists.set_function(addr, fn, on) 265 elif action == "emergency_stop": 266 await self._consists.emergency_stop(addr) 267 else: 268 raise JsonServiceError(400, f"unknown consist action: {action!r}") 269 return _project(self._require(addr), self._consists)