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 by name.
  • put → create-or-replace; the request body carries the whole tree under tree (a nested dict matching the LogixNG YAML shape).
  • delete → drop by name; also removes the on-disk YAML.
  • post → verbs on command:
    • enable / disable — flip the enabled flag on the whole program.
    • run — execute every conditional in order (fire-and-forget for the caller; the service awaits so failures surface).
    • run_conditional with conditional_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)
class LogixNGService(jmri_server.service.JsonService):
 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.

topic = 'logixng'
async def on_list(self, data: dict[str, typing.Any] | None) -> list[dict[str, typing.Any]]:
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        ]
async def on_get(self, data: dict[str, typing.Any] | None) -> dict[str, typing.Any]:
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)
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 + optional userName.
  • a full tree: tree field 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)