jmri_server.services.logixng
LogixNGService — JSON topic logixng.
Exposes the ~jmri_logixng.LogixNGManager registered on the
server context. There's no JMRI-Java precedent for this topic; the
wire shape follows the pattern of the other bean-backed services
(name / userName / comment at the top plus type-specific
fields) so consumers can reuse the same envelope handling.
Wire shape::
{
"name": "IQ1",
"userName": "Yard automation",
"enabled": true,
"conditionals": [
{
"id": "conditional-abc",
"user_name": "Turn on light when track occupied",
"enabled": true,
"actions": [
{"type": "action:set-turnout", "turnout": "IT1",
"state": "CLOSED"},
...
]
}
]
}
Methods:
list→ array of the above.get→ one byname.put→ create-or-replace; the request body carries the whole tree undertree(a nested dict matching the LogixNG YAML shape).delete→ drop byname; also removes the on-disk YAML.post→ verbs oncommand:enable/disable— flip theenabledflag on the whole program.run— execute every conditional in order (fire-and-forget for the caller; the service awaits so failures surface).run_conditionalwithconditional_id— run one.
1"""``LogixNGService`` — JSON topic ``logixng``. 2 3Exposes the :class:`~jmri_logixng.LogixNGManager` registered on the 4server context. There's no JMRI-Java precedent for this topic; the 5wire shape follows the pattern of the other bean-backed services 6(``name`` / ``userName`` / ``comment`` at the top plus type-specific 7fields) so consumers can reuse the same envelope handling. 8 9Wire shape:: 10 11 { 12 "name": "IQ1", 13 "userName": "Yard automation", 14 "enabled": true, 15 "conditionals": [ 16 { 17 "id": "conditional-abc", 18 "user_name": "Turn on light when track occupied", 19 "enabled": true, 20 "actions": [ 21 {"type": "action:set-turnout", "turnout": "IT1", 22 "state": "CLOSED"}, 23 ... 24 ] 25 } 26 ] 27 } 28 29Methods: 30 31* ``list`` → array of the above. 32* ``get`` → one by ``name``. 33* ``put`` → create-or-replace; the request body carries the whole 34 tree under ``tree`` (a nested dict matching the LogixNG YAML shape). 35* ``delete`` → drop by ``name``; also removes the on-disk YAML. 36* ``post`` → verbs on ``command``: 37 - ``enable`` / ``disable`` — flip the ``enabled`` flag on the 38 whole program. 39 - ``run`` — execute every conditional in order (fire-and-forget 40 for the caller; the service awaits so failures surface). 41 - ``run_conditional`` with ``conditional_id`` — run one. 42""" 43from __future__ import annotations 44 45import logging 46from typing import Any 47 48from jmri_logixng import ExecutionContext, LogixNG, LogixNGManager 49from jmri_logixng.serialize import from_dict 50 51from jmri_server.service import JsonService, JsonServiceError 52 53_log = logging.getLogger(__name__) 54 55 56def _project(system_name: str, prog: LogixNG) -> dict[str, Any]: 57 """Full-tree projection so a mobile client / web page can render 58 the whole program without a follow-up call. Matches the tree the 59 YAML serializer emits + a ``name`` slot for the system name.""" 60 return { 61 "name": system_name, 62 "userName": prog.user_name or None, 63 "enabled": prog.enabled, 64 "conditionals": [c.to_dict() for c in prog.conditionals], 65 } 66 67 68def _require_manager(ctx: Any) -> LogixNGManager: 69 mgr = getattr(ctx, "logix_ng", None) 70 if mgr is None: 71 raise JsonServiceError( 72 503, "LogixNG service unavailable (jmri-logixng not installed)", 73 ) 74 return mgr 75 76 77class LogixNGService(JsonService): 78 topic = "logixng" 79 80 async def on_list( 81 self, data: dict[str, Any] | None, 82 ) -> list[dict[str, Any]]: 83 mgr = _require_manager(self.ctx) 84 return [ 85 _project(name, mgr.get(name)) # type: ignore[arg-type] 86 for name in mgr.names() 87 ] 88 89 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 90 mgr = _require_manager(self.ctx) 91 name = self._require_name(data or {}) 92 prog = mgr.get(name) 93 if prog is None: 94 raise JsonServiceError(404, f"no logixng named {name!r}") 95 return _project(name, prog) 96 97 async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]: 98 """Create-or-replace by name. Body may be: 99 100 * an empty program: only ``name`` + optional ``userName``. 101 * a full tree: ``tree`` field carrying the nested dict the 102 YAML serializer produced. 103 """ 104 mgr = _require_manager(self.ctx) 105 data = data or {} 106 name = self._require_name(data) 107 tree = data.get("tree") 108 if tree is None: 109 prog = await mgr.create( 110 name, 111 user_name=str(data.get("userName") or ""), 112 enabled=bool(data.get("enabled", True)), 113 ) 114 else: 115 if not isinstance(tree, dict): 116 raise JsonServiceError( 117 400, "logixng.put tree must be a mapping", 118 ) 119 try: 120 node = from_dict(tree) 121 except (ValueError, KeyError, TypeError) as e: 122 raise JsonServiceError( 123 400, f"logixng tree decode failed: {e}", 124 ) from None 125 if not isinstance(node, LogixNG): 126 raise JsonServiceError( 127 400, "logixng tree must have type=logixng:root", 128 ) 129 prog = await mgr.put(name, node) 130 self._persist(mgr, name) 131 return _project(name, prog) 132 133 async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]: 134 mgr = _require_manager(self.ctx) 135 name = self._require_name(data or {}) 136 if not await mgr.delete(name): 137 raise JsonServiceError(404, f"no logixng named {name!r}") 138 self._persist_delete(name) 139 return {"name": name, "deleted": True} 140 141 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 142 mgr = _require_manager(self.ctx) 143 data = data or {} 144 name = self._require_name(data) 145 prog = mgr.get(name) 146 if prog is None: 147 raise JsonServiceError(404, f"no logixng named {name!r}") 148 command = data.get("command") 149 if command == "enable": 150 prog.enabled = True 151 await mgr.updated.emit(prog) 152 self._persist(mgr, name) 153 elif command == "disable": 154 prog.enabled = False 155 await mgr.updated.emit(prog) 156 self._persist(mgr, name) 157 elif command == "run": 158 await self._run(prog) 159 elif command == "run_conditional": 160 target_id = data.get("conditional_id") 161 if not isinstance(target_id, str): 162 raise JsonServiceError( 163 400, 164 "logixng.run_conditional requires 'conditional_id'", 165 ) 166 await self._run_one(prog, target_id) 167 else: 168 raise JsonServiceError(400, f"unknown logixng command: {command!r}") 169 return _project(name, prog) 170 171 # ---- helpers ------------------------------------------------------- 172 173 async def _run(self, prog: LogixNG) -> None: 174 exec_ctx = ExecutionContext(connection=self.ctx.connection) 175 try: 176 await prog.execute_all(exec_ctx) 177 except Exception as e: # noqa: BLE001 178 _log.exception("LogixNG %s run failed", prog.user_name or "?") 179 raise JsonServiceError(500, f"logixng run failed: {e}") from None 180 181 async def _run_one(self, prog: LogixNG, conditional_id: str) -> None: 182 target = next( 183 (c for c in prog.conditionals if c.id == conditional_id), 184 None, 185 ) 186 if target is None: 187 raise JsonServiceError( 188 404, f"no conditional {conditional_id!r} in this logixng", 189 ) 190 exec_ctx = ExecutionContext(connection=self.ctx.connection) 191 try: 192 await target.execute(exec_ctx) 193 except Exception as e: # noqa: BLE001 194 _log.exception("LogixNG conditional %s run failed", conditional_id) 195 raise JsonServiceError( 196 500, f"conditional run failed: {e}", 197 ) from None 198 199 def _require_name(self, data: dict[str, Any]) -> str: 200 name = data.get("name") 201 if not isinstance(name, str) or not name: 202 raise JsonServiceError(400, "logixng request requires 'name'") 203 return name 204 205 def _persist(self, mgr: LogixNGManager, name: str) -> None: 206 root = getattr(self.ctx, "_logixng_dir", None) 207 if root is None: 208 return 209 mgr.save_one(root, name) 210 211 def _persist_delete(self, name: str) -> None: 212 root = getattr(self.ctx, "_logixng_dir", None) 213 mgr = _require_manager(self.ctx) 214 if root is not None: 215 mgr.delete_on_disk(root, name)
78class LogixNGService(JsonService): 79 topic = "logixng" 80 81 async def on_list( 82 self, data: dict[str, Any] | None, 83 ) -> list[dict[str, Any]]: 84 mgr = _require_manager(self.ctx) 85 return [ 86 _project(name, mgr.get(name)) # type: ignore[arg-type] 87 for name in mgr.names() 88 ] 89 90 async def on_get(self, data: dict[str, Any] | None) -> dict[str, Any]: 91 mgr = _require_manager(self.ctx) 92 name = self._require_name(data or {}) 93 prog = mgr.get(name) 94 if prog is None: 95 raise JsonServiceError(404, f"no logixng named {name!r}") 96 return _project(name, prog) 97 98 async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]: 99 """Create-or-replace by name. Body may be: 100 101 * an empty program: only ``name`` + optional ``userName``. 102 * a full tree: ``tree`` field carrying the nested dict the 103 YAML serializer produced. 104 """ 105 mgr = _require_manager(self.ctx) 106 data = data or {} 107 name = self._require_name(data) 108 tree = data.get("tree") 109 if tree is None: 110 prog = await mgr.create( 111 name, 112 user_name=str(data.get("userName") or ""), 113 enabled=bool(data.get("enabled", True)), 114 ) 115 else: 116 if not isinstance(tree, dict): 117 raise JsonServiceError( 118 400, "logixng.put tree must be a mapping", 119 ) 120 try: 121 node = from_dict(tree) 122 except (ValueError, KeyError, TypeError) as e: 123 raise JsonServiceError( 124 400, f"logixng tree decode failed: {e}", 125 ) from None 126 if not isinstance(node, LogixNG): 127 raise JsonServiceError( 128 400, "logixng tree must have type=logixng:root", 129 ) 130 prog = await mgr.put(name, node) 131 self._persist(mgr, name) 132 return _project(name, prog) 133 134 async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]: 135 mgr = _require_manager(self.ctx) 136 name = self._require_name(data or {}) 137 if not await mgr.delete(name): 138 raise JsonServiceError(404, f"no logixng named {name!r}") 139 self._persist_delete(name) 140 return {"name": name, "deleted": True} 141 142 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 143 mgr = _require_manager(self.ctx) 144 data = data or {} 145 name = self._require_name(data) 146 prog = mgr.get(name) 147 if prog is None: 148 raise JsonServiceError(404, f"no logixng named {name!r}") 149 command = data.get("command") 150 if command == "enable": 151 prog.enabled = True 152 await mgr.updated.emit(prog) 153 self._persist(mgr, name) 154 elif command == "disable": 155 prog.enabled = False 156 await mgr.updated.emit(prog) 157 self._persist(mgr, name) 158 elif command == "run": 159 await self._run(prog) 160 elif command == "run_conditional": 161 target_id = data.get("conditional_id") 162 if not isinstance(target_id, str): 163 raise JsonServiceError( 164 400, 165 "logixng.run_conditional requires 'conditional_id'", 166 ) 167 await self._run_one(prog, target_id) 168 else: 169 raise JsonServiceError(400, f"unknown logixng command: {command!r}") 170 return _project(name, prog) 171 172 # ---- helpers ------------------------------------------------------- 173 174 async def _run(self, prog: LogixNG) -> None: 175 exec_ctx = ExecutionContext(connection=self.ctx.connection) 176 try: 177 await prog.execute_all(exec_ctx) 178 except Exception as e: # noqa: BLE001 179 _log.exception("LogixNG %s run failed", prog.user_name or "?") 180 raise JsonServiceError(500, f"logixng run failed: {e}") from None 181 182 async def _run_one(self, prog: LogixNG, conditional_id: str) -> None: 183 target = next( 184 (c for c in prog.conditionals if c.id == conditional_id), 185 None, 186 ) 187 if target is None: 188 raise JsonServiceError( 189 404, f"no conditional {conditional_id!r} in this logixng", 190 ) 191 exec_ctx = ExecutionContext(connection=self.ctx.connection) 192 try: 193 await target.execute(exec_ctx) 194 except Exception as e: # noqa: BLE001 195 _log.exception("LogixNG conditional %s run failed", conditional_id) 196 raise JsonServiceError( 197 500, f"conditional run failed: {e}", 198 ) from None 199 200 def _require_name(self, data: dict[str, Any]) -> str: 201 name = data.get("name") 202 if not isinstance(name, str) or not name: 203 raise JsonServiceError(400, "logixng request requires 'name'") 204 return name 205 206 def _persist(self, mgr: LogixNGManager, name: str) -> None: 207 root = getattr(self.ctx, "_logixng_dir", None) 208 if root is None: 209 return 210 mgr.save_one(root, name) 211 212 def _persist_delete(self, name: str) -> None: 213 root = getattr(self.ctx, "_logixng_dir", None) 214 mgr = _require_manager(self.ctx) 215 if root is not None: 216 mgr.delete_on_disk(root, name)
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.
async def
on_put(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
98 async def on_put(self, data: dict[str, Any] | None) -> dict[str, Any]: 99 """Create-or-replace by name. Body may be: 100 101 * an empty program: only ``name`` + optional ``userName``. 102 * a full tree: ``tree`` field carrying the nested dict the 103 YAML serializer produced. 104 """ 105 mgr = _require_manager(self.ctx) 106 data = data or {} 107 name = self._require_name(data) 108 tree = data.get("tree") 109 if tree is None: 110 prog = await mgr.create( 111 name, 112 user_name=str(data.get("userName") or ""), 113 enabled=bool(data.get("enabled", True)), 114 ) 115 else: 116 if not isinstance(tree, dict): 117 raise JsonServiceError( 118 400, "logixng.put tree must be a mapping", 119 ) 120 try: 121 node = from_dict(tree) 122 except (ValueError, KeyError, TypeError) as e: 123 raise JsonServiceError( 124 400, f"logixng tree decode failed: {e}", 125 ) from None 126 if not isinstance(node, LogixNG): 127 raise JsonServiceError( 128 400, "logixng tree must have type=logixng:root", 129 ) 130 prog = await mgr.put(name, node) 131 self._persist(mgr, name) 132 return _project(name, prog)
Create-or-replace by name. Body may be:
- an empty program: only
name+ optionaluserName. - a full tree:
treefield carrying the nested dict the YAML serializer produced.
async def
on_delete(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
134 async def on_delete(self, data: dict[str, Any] | None) -> dict[str, Any]: 135 mgr = _require_manager(self.ctx) 136 name = self._require_name(data or {}) 137 if not await mgr.delete(name): 138 raise JsonServiceError(404, f"no logixng named {name!r}") 139 self._persist_delete(name) 140 return {"name": name, "deleted": True}
async def
on_post(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
142 async def on_post(self, data: dict[str, Any] | None) -> dict[str, Any]: 143 mgr = _require_manager(self.ctx) 144 data = data or {} 145 name = self._require_name(data) 146 prog = mgr.get(name) 147 if prog is None: 148 raise JsonServiceError(404, f"no logixng named {name!r}") 149 command = data.get("command") 150 if command == "enable": 151 prog.enabled = True 152 await mgr.updated.emit(prog) 153 self._persist(mgr, name) 154 elif command == "disable": 155 prog.enabled = False 156 await mgr.updated.emit(prog) 157 self._persist(mgr, name) 158 elif command == "run": 159 await self._run(prog) 160 elif command == "run_conditional": 161 target_id = data.get("conditional_id") 162 if not isinstance(target_id, str): 163 raise JsonServiceError( 164 400, 165 "logixng.run_conditional requires 'conditional_id'", 166 ) 167 await self._run_one(prog, target_id) 168 else: 169 raise JsonServiceError(400, f"unknown logixng command: {command!r}") 170 return _project(name, prog)