jmri_server.programming_queue

ProgrammingQueue — serialized per-loco CV writes with progress + cancel.

DCC programming is slow: direct-mode writes on the service track take several seconds per CV. A full loco can have dozens of CVs the user wants to write. Firing them all off in parallel would overwhelm the programming track (which serializes at the CS anyway) and race the LocoCvStore atomic writes.

This module owns a per-loco queue. One batch runs at a time. A cancel flag causes the queue to stop after the in-flight write completes — matching the user's spec ("cancel after the completion of the in-flight operation") because DCC hardware can't safely abort a write already on the wire.

Client flow:

POST /programmer/loco/<id>/write-batch  → 202 + batch id
GET  /programmer/loco/<id>/batch/status → progress
POST /programmer/loco/<id>/batch/cancel → stop after current write
POST /programmer/loco/<id>/batch/clear  → clear terminal-state batch
  1"""``ProgrammingQueue`` — serialized per-loco CV writes with progress + cancel.
  2
  3DCC programming is slow: direct-mode writes on the service track take
  4several seconds *per CV*. A full loco can have dozens of CVs the user
  5wants to write. Firing them all off in parallel would overwhelm the
  6programming track (which serializes at the CS anyway) and race the
  7LocoCvStore atomic writes.
  8
  9This module owns a per-loco queue. One batch runs at a time. A cancel
 10flag causes the queue to stop after the *in-flight* write completes —
 11matching the user's spec ("cancel after the completion of the in-flight
 12operation") because DCC hardware can't safely abort a write already on
 13the wire.
 14
 15Client flow:
 16
 17    POST /programmer/loco/<id>/write-batch  → 202 + batch id
 18    GET  /programmer/loco/<id>/batch/status → progress
 19    POST /programmer/loco/<id>/batch/cancel → stop after current write
 20    POST /programmer/loco/<id>/batch/clear  → clear terminal-state batch
 21"""
 22from __future__ import annotations
 23
 24import asyncio
 25import logging
 26import time
 27import uuid
 28from collections.abc import Awaitable, Callable
 29from dataclasses import dataclass, field
 30from enum import StrEnum
 31from typing import Any
 32
 33_log = logging.getLogger(__name__)
 34
 35
 36class BatchState(StrEnum):
 37    """High-level lifecycle for a queued batch."""
 38    PENDING     = "pending"
 39    IN_PROGRESS = "in_progress"
 40    DONE        = "done"
 41    CANCELLED   = "cancelled"
 42    ERROR       = "error"
 43
 44
 45class WriteResult(StrEnum):
 46    """Per-write outcome inside a batch."""
 47    PENDING   = "pending"
 48    WRITING   = "writing"
 49    DONE      = "done"
 50    ERROR     = "error"
 51    CANCELLED = "cancelled"
 52
 53
 54@dataclass
 55class BatchWrite:
 56    """One CV operation inside a batch.
 57
 58    * ``kind`` — ``"write"`` (default) or ``"read"``. Selects which
 59      callback the queue dispatches to. Reads leave ``value`` as 0 on
 60      the request side and store the read byte in ``value`` once the
 61      operation completes so downstream consumers see a uniform
 62      ``{cv, value, item, result}`` snapshot.
 63    * ``cv`` — target CV number
 64    * ``value`` — byte value: for writes this is the requested value;
 65      for reads it starts at 0 and holds the read result on completion.
 66    * ``item`` — display label for the progress UI (variable ``item``
 67      name from the decoder catalog)
 68    * ``result`` — updated as the queue processes this op
 69    * ``error`` — filled when ``result == ERROR``
 70    """
 71    cv: int
 72    value: int = 0
 73    item: str = ""
 74    kind: str = "write"
 75    result: WriteResult = WriteResult.PENDING
 76    error: str = ""
 77
 78
 79# Write callback: returns the value the CS confirmed committed.
 80WriteCallback = Callable[[int, int, str, dict[str, Any]], Awaitable[int]]
 81
 82# Read callback: returns the byte read from the decoder.
 83ReadCallback = Callable[[int, str, dict[str, Any]], Awaitable[int]]
 84
 85
 86@dataclass
 87class Batch:
 88    """One in-flight batch of writes."""
 89
 90    batch_id: str
 91    loco_id: str
 92    writes: list[BatchWrite]
 93    mode: str = "service"
 94    address: int | None = None
 95    long_address: bool = False
 96    file_name: str | None = None
 97    state: BatchState = BatchState.PENDING
 98    error: str = ""
 99    started_at: float = field(default_factory=time.monotonic)
100    finished_at: float | None = None
101    _cancel: asyncio.Event = field(default_factory=asyncio.Event)
102    _task: asyncio.Task[None] | None = None
103
104    @property
105    def completed(self) -> int:
106        return sum(
107            1 for w in self.writes
108            if w.result in (WriteResult.DONE, WriteResult.ERROR, WriteResult.CANCELLED)
109        )
110
111    @property
112    def total(self) -> int:
113        return len(self.writes)
114
115    @property
116    def in_flight(self) -> BatchWrite | None:
117        for w in self.writes:
118            if w.result is WriteResult.WRITING:
119                return w
120        return None
121
122    def is_terminal(self) -> bool:
123        return self.state in (
124            BatchState.DONE, BatchState.CANCELLED, BatchState.ERROR,
125        )
126
127    def request_cancel(self) -> None:
128        """Set the cancel flag; the worker stops after the current write."""
129        self._cancel.set()
130
131    def to_status(self) -> dict[str, Any]:
132        """Serializable snapshot for the polling endpoint."""
133        in_flight = self.in_flight
134        return {
135            "batch_id": self.batch_id,
136            "loco_id": self.loco_id,
137            "state": self.state.value,
138            "completed": self.completed,
139            "total": self.total,
140            "in_flight": {
141                "cv": in_flight.cv, "item": in_flight.item,
142                "value": in_flight.value,
143            } if in_flight else None,
144            "writes": [
145                {
146                    "cv": w.cv, "value": w.value, "item": w.item,
147                    "result": w.result.value, "error": w.error,
148                }
149                for w in self.writes
150            ],
151            "error": self.error,
152            "elapsed_s": (
153                (self.finished_at or time.monotonic()) - self.started_at
154            ),
155        }
156
157
158class BatchInFlightError(Exception):
159    """Raised when a caller tries to start a new batch while one is running."""
160
161    def __init__(self, loco_id: str, batch_id: str) -> None:
162        super().__init__(
163            f"loco {loco_id!r} already has batch {batch_id!r} in flight; "
164            f"cancel or wait for it to finish first",
165        )
166        self.loco_id = loco_id
167        self.batch_id = batch_id
168
169
170class ProgrammingQueue:
171    """Holds the active :class:`Batch` for one loco.
172
173    A completed batch stays visible on the queue until the caller either
174    starts a new batch (which replaces it) or explicitly clears it. That
175    lets the UI show the final result without racing the completion
176    notification.
177
178    ``read_callback`` is optional; when omitted, only write batches can
179    run (a read op in the batch will fail with a NotImplementedError).
180    """
181
182    def __init__(
183        self, loco_id: str, write_callback: WriteCallback,
184        read_callback: ReadCallback | None = None,
185    ) -> None:
186        self._loco_id = loco_id
187        self._write_callback = write_callback
188        self._read_callback = read_callback
189        self._current: Batch | None = None
190
191    @property
192    def loco_id(self) -> str:
193        return self._loco_id
194
195    @property
196    def current(self) -> Batch | None:
197        return self._current
198
199    def is_busy(self) -> bool:
200        return (
201            self._current is not None
202            and self._current.state is BatchState.IN_PROGRESS
203        )
204
205    def clear(self) -> None:
206        """Drop a terminal-state batch. Raises if a batch is still running."""
207        if self._current is not None and not self._current.is_terminal():
208            raise BatchInFlightError(self._loco_id, self._current.batch_id)
209        self._current = None
210
211    def cancel(self) -> Batch | None:
212        """Signal cancel on the current batch. Returns it (or None)."""
213        if self._current is None or self._current.is_terminal():
214            return self._current
215        self._current.request_cancel()
216        return self._current
217
218    async def start(
219        self,
220        writes: list[BatchWrite],
221        *,
222        mode: str = "service",
223        address: int | None = None,
224        long_address: bool = False,
225        file_name: str | None = None,
226    ) -> Batch:
227        """Queue *writes* and start the worker.
228
229        Raises :class:`BatchInFlightError` if a batch is already running.
230        Callers should either await its completion, cancel it, or clear
231        it (if terminal) before starting a new one.
232        """
233        if self._current is not None and not self._current.is_terminal():
234            raise BatchInFlightError(self._loco_id, self._current.batch_id)
235        batch = Batch(
236            batch_id=uuid.uuid4().hex[:12],
237            loco_id=self._loco_id,
238            writes=list(writes),
239            mode=mode,
240            address=address,
241            long_address=long_address,
242            file_name=file_name,
243        )
244        self._current = batch
245        batch.state = BatchState.IN_PROGRESS
246        batch._task = asyncio.create_task(
247            self._run(batch), name=f"batch-{self._loco_id}-{batch.batch_id}",
248        )
249        return batch
250
251    async def _run(self, batch: Batch) -> None:
252        """Iterate ops serially, honouring the cancel flag between each."""
253        try:
254            for op in batch.writes:
255                if batch._cancel.is_set():
256                    op.result = WriteResult.CANCELLED
257                    continue
258                op.result = WriteResult.WRITING
259                extra: dict[str, Any] = {
260                    "mode": batch.mode,
261                    "file_name": batch.file_name,
262                }
263                if batch.address is not None:
264                    extra["address"] = batch.address
265                    extra["long_address"] = batch.long_address
266                try:
267                    if op.kind == "read":
268                        if self._read_callback is None:
269                            raise NotImplementedError(
270                                "read op requested but no read_callback configured",
271                            )
272                        value = await self._read_callback(
273                            op.cv, op.item, extra,
274                        )
275                        # Reads land the byte back into ``value`` so
276                        # the status snapshot's shape stays uniform
277                        # with writes.
278                        op.value = int(value)
279                    else:
280                        await self._write_callback(
281                            op.cv, op.value, op.item, extra,
282                        )
283                except Exception as e:  # noqa: BLE001
284                    op.result = WriteResult.ERROR
285                    op.error = str(e)
286                    _log.warning(
287                        "batch %s: CV %d %s failed: %s",
288                        batch.batch_id, op.cv, op.kind, e,
289                    )
290                    continue
291                op.result = WriteResult.DONE
292            # Terminal state: cancelled if flag was set at any point,
293            # error if all failed, otherwise done.
294            any_pending = any(w.result is WriteResult.PENDING for w in batch.writes)
295            any_cancelled = any(w.result is WriteResult.CANCELLED for w in batch.writes)
296            all_errored = batch.writes and all(
297                w.result is WriteResult.ERROR for w in batch.writes
298            )
299            if batch._cancel.is_set() or any_cancelled or any_pending:
300                batch.state = BatchState.CANCELLED
301            elif all_errored:
302                batch.state = BatchState.ERROR
303                batch.error = "all writes failed"
304            else:
305                batch.state = BatchState.DONE
306        except asyncio.CancelledError:
307            batch.state = BatchState.CANCELLED
308            raise
309        except Exception as e:  # noqa: BLE001
310            batch.state = BatchState.ERROR
311            batch.error = str(e)
312            _log.exception("batch %s crashed", batch.batch_id)
313        finally:
314            batch.finished_at = time.monotonic()
315
316
317def get_or_create_queue(
318    ctx: Any, loco_id: str, write_callback: WriteCallback,
319    read_callback: ReadCallback | None = None,
320) -> ProgrammingQueue:
321    """Fetch the queue for *loco_id* off *ctx*, creating on first use.
322
323    The queue registry lives on ``ctx._programming_queues`` (dict). One
324    queue per loco keeps batches scoped so cancelling loco A doesn't
325    stop loco B's operation. When an existing queue is returned and a
326    non-None *read_callback* is supplied, the queue's read callback is
327    updated in place — lets a route that didn't set up reads on first
328    use enable them later without swapping the queue instance.
329    """
330    queues: dict[str, ProgrammingQueue] = getattr(ctx, "_programming_queues", None)
331    if queues is None:
332        queues = {}
333        ctx._programming_queues = queues  # type: ignore[attr-defined]
334    if loco_id not in queues:
335        queues[loco_id] = ProgrammingQueue(
336            loco_id, write_callback, read_callback=read_callback,
337        )
338    elif read_callback is not None and queues[loco_id]._read_callback is None:
339        queues[loco_id]._read_callback = read_callback
340    return queues[loco_id]
class BatchState(enum.StrEnum):
37class BatchState(StrEnum):
38    """High-level lifecycle for a queued batch."""
39    PENDING     = "pending"
40    IN_PROGRESS = "in_progress"
41    DONE        = "done"
42    CANCELLED   = "cancelled"
43    ERROR       = "error"

High-level lifecycle for a queued batch.

PENDING = <BatchState.PENDING: 'pending'>
IN_PROGRESS = <BatchState.IN_PROGRESS: 'in_progress'>
DONE = <BatchState.DONE: 'done'>
CANCELLED = <BatchState.CANCELLED: 'cancelled'>
ERROR = <BatchState.ERROR: 'error'>
class WriteResult(enum.StrEnum):
46class WriteResult(StrEnum):
47    """Per-write outcome inside a batch."""
48    PENDING   = "pending"
49    WRITING   = "writing"
50    DONE      = "done"
51    ERROR     = "error"
52    CANCELLED = "cancelled"

Per-write outcome inside a batch.

PENDING = <WriteResult.PENDING: 'pending'>
WRITING = <WriteResult.WRITING: 'writing'>
DONE = <WriteResult.DONE: 'done'>
ERROR = <WriteResult.ERROR: 'error'>
CANCELLED = <WriteResult.CANCELLED: 'cancelled'>
@dataclass
class BatchWrite:
55@dataclass
56class BatchWrite:
57    """One CV operation inside a batch.
58
59    * ``kind`` — ``"write"`` (default) or ``"read"``. Selects which
60      callback the queue dispatches to. Reads leave ``value`` as 0 on
61      the request side and store the read byte in ``value`` once the
62      operation completes so downstream consumers see a uniform
63      ``{cv, value, item, result}`` snapshot.
64    * ``cv`` — target CV number
65    * ``value`` — byte value: for writes this is the requested value;
66      for reads it starts at 0 and holds the read result on completion.
67    * ``item`` — display label for the progress UI (variable ``item``
68      name from the decoder catalog)
69    * ``result`` — updated as the queue processes this op
70    * ``error`` — filled when ``result == ERROR``
71    """
72    cv: int
73    value: int = 0
74    item: str = ""
75    kind: str = "write"
76    result: WriteResult = WriteResult.PENDING
77    error: str = ""

One CV operation inside a batch.

  • kind — "write" (default) or "read". Selects which callback the queue dispatches to. Reads leave value as 0 on the request side and store the read byte in value once the operation completes so downstream consumers see a uniform {cv, value, item, result} snapshot.
  • cv — target CV number
  • value — byte value: for writes this is the requested value; for reads it starts at 0 and holds the read result on completion.
  • item — display label for the progress UI (variable item name from the decoder catalog)
  • result — updated as the queue processes this op
  • error — filled when result == ERROR
BatchWrite( cv: int, value: int = 0, item: str = '', kind: str = 'write', result: WriteResult = <WriteResult.PENDING: 'pending'>, error: str = '')
cv: int
value: int = 0
item: str = ''
kind: str = 'write'
result: WriteResult = <WriteResult.PENDING: 'pending'>
error: str = ''
WriteCallback = collections.abc.Callable[[int, int, str, dict[str, typing.Any]], collections.abc.Awaitable[int]]
ReadCallback = collections.abc.Callable[[int, str, dict[str, typing.Any]], collections.abc.Awaitable[int]]
@dataclass
class Batch:
 87@dataclass
 88class Batch:
 89    """One in-flight batch of writes."""
 90
 91    batch_id: str
 92    loco_id: str
 93    writes: list[BatchWrite]
 94    mode: str = "service"
 95    address: int | None = None
 96    long_address: bool = False
 97    file_name: str | None = None
 98    state: BatchState = BatchState.PENDING
 99    error: str = ""
100    started_at: float = field(default_factory=time.monotonic)
101    finished_at: float | None = None
102    _cancel: asyncio.Event = field(default_factory=asyncio.Event)
103    _task: asyncio.Task[None] | None = None
104
105    @property
106    def completed(self) -> int:
107        return sum(
108            1 for w in self.writes
109            if w.result in (WriteResult.DONE, WriteResult.ERROR, WriteResult.CANCELLED)
110        )
111
112    @property
113    def total(self) -> int:
114        return len(self.writes)
115
116    @property
117    def in_flight(self) -> BatchWrite | None:
118        for w in self.writes:
119            if w.result is WriteResult.WRITING:
120                return w
121        return None
122
123    def is_terminal(self) -> bool:
124        return self.state in (
125            BatchState.DONE, BatchState.CANCELLED, BatchState.ERROR,
126        )
127
128    def request_cancel(self) -> None:
129        """Set the cancel flag; the worker stops after the current write."""
130        self._cancel.set()
131
132    def to_status(self) -> dict[str, Any]:
133        """Serializable snapshot for the polling endpoint."""
134        in_flight = self.in_flight
135        return {
136            "batch_id": self.batch_id,
137            "loco_id": self.loco_id,
138            "state": self.state.value,
139            "completed": self.completed,
140            "total": self.total,
141            "in_flight": {
142                "cv": in_flight.cv, "item": in_flight.item,
143                "value": in_flight.value,
144            } if in_flight else None,
145            "writes": [
146                {
147                    "cv": w.cv, "value": w.value, "item": w.item,
148                    "result": w.result.value, "error": w.error,
149                }
150                for w in self.writes
151            ],
152            "error": self.error,
153            "elapsed_s": (
154                (self.finished_at or time.monotonic()) - self.started_at
155            ),
156        }

One in-flight batch of writes.

Batch( batch_id: str, loco_id: str, writes: list[BatchWrite], mode: str = 'service', address: int | None = None, long_address: bool = False, file_name: str | None = None, state: BatchState = <BatchState.PENDING: 'pending'>, error: str = '', started_at: float = <factory>, finished_at: float | None = None, _cancel: asyncio.locks.Event = <factory>, _task: _asyncio.Task[None] | None = None)
batch_id: str
loco_id: str
writes: list[BatchWrite]
mode: str = 'service'
address: int | None = None
long_address: bool = False
file_name: str | None = None
state: BatchState = <BatchState.PENDING: 'pending'>
error: str = ''
started_at: float
finished_at: float | None = None
completed: int
105    @property
106    def completed(self) -> int:
107        return sum(
108            1 for w in self.writes
109            if w.result in (WriteResult.DONE, WriteResult.ERROR, WriteResult.CANCELLED)
110        )
total: int
112    @property
113    def total(self) -> int:
114        return len(self.writes)
in_flight: BatchWrite | None
116    @property
117    def in_flight(self) -> BatchWrite | None:
118        for w in self.writes:
119            if w.result is WriteResult.WRITING:
120                return w
121        return None
def is_terminal(self) -> bool:
123    def is_terminal(self) -> bool:
124        return self.state in (
125            BatchState.DONE, BatchState.CANCELLED, BatchState.ERROR,
126        )
def request_cancel(self) -> None:
128    def request_cancel(self) -> None:
129        """Set the cancel flag; the worker stops after the current write."""
130        self._cancel.set()

Set the cancel flag; the worker stops after the current write.

def to_status(self) -> dict[str, typing.Any]:
132    def to_status(self) -> dict[str, Any]:
133        """Serializable snapshot for the polling endpoint."""
134        in_flight = self.in_flight
135        return {
136            "batch_id": self.batch_id,
137            "loco_id": self.loco_id,
138            "state": self.state.value,
139            "completed": self.completed,
140            "total": self.total,
141            "in_flight": {
142                "cv": in_flight.cv, "item": in_flight.item,
143                "value": in_flight.value,
144            } if in_flight else None,
145            "writes": [
146                {
147                    "cv": w.cv, "value": w.value, "item": w.item,
148                    "result": w.result.value, "error": w.error,
149                }
150                for w in self.writes
151            ],
152            "error": self.error,
153            "elapsed_s": (
154                (self.finished_at or time.monotonic()) - self.started_at
155            ),
156        }

Serializable snapshot for the polling endpoint.

class BatchInFlightError(builtins.Exception):
159class BatchInFlightError(Exception):
160    """Raised when a caller tries to start a new batch while one is running."""
161
162    def __init__(self, loco_id: str, batch_id: str) -> None:
163        super().__init__(
164            f"loco {loco_id!r} already has batch {batch_id!r} in flight; "
165            f"cancel or wait for it to finish first",
166        )
167        self.loco_id = loco_id
168        self.batch_id = batch_id

Raised when a caller tries to start a new batch while one is running.

BatchInFlightError(loco_id: str, batch_id: str)
162    def __init__(self, loco_id: str, batch_id: str) -> None:
163        super().__init__(
164            f"loco {loco_id!r} already has batch {batch_id!r} in flight; "
165            f"cancel or wait for it to finish first",
166        )
167        self.loco_id = loco_id
168        self.batch_id = batch_id
loco_id
batch_id
class ProgrammingQueue:
171class ProgrammingQueue:
172    """Holds the active :class:`Batch` for one loco.
173
174    A completed batch stays visible on the queue until the caller either
175    starts a new batch (which replaces it) or explicitly clears it. That
176    lets the UI show the final result without racing the completion
177    notification.
178
179    ``read_callback`` is optional; when omitted, only write batches can
180    run (a read op in the batch will fail with a NotImplementedError).
181    """
182
183    def __init__(
184        self, loco_id: str, write_callback: WriteCallback,
185        read_callback: ReadCallback | None = None,
186    ) -> None:
187        self._loco_id = loco_id
188        self._write_callback = write_callback
189        self._read_callback = read_callback
190        self._current: Batch | None = None
191
192    @property
193    def loco_id(self) -> str:
194        return self._loco_id
195
196    @property
197    def current(self) -> Batch | None:
198        return self._current
199
200    def is_busy(self) -> bool:
201        return (
202            self._current is not None
203            and self._current.state is BatchState.IN_PROGRESS
204        )
205
206    def clear(self) -> None:
207        """Drop a terminal-state batch. Raises if a batch is still running."""
208        if self._current is not None and not self._current.is_terminal():
209            raise BatchInFlightError(self._loco_id, self._current.batch_id)
210        self._current = None
211
212    def cancel(self) -> Batch | None:
213        """Signal cancel on the current batch. Returns it (or None)."""
214        if self._current is None or self._current.is_terminal():
215            return self._current
216        self._current.request_cancel()
217        return self._current
218
219    async def start(
220        self,
221        writes: list[BatchWrite],
222        *,
223        mode: str = "service",
224        address: int | None = None,
225        long_address: bool = False,
226        file_name: str | None = None,
227    ) -> Batch:
228        """Queue *writes* and start the worker.
229
230        Raises :class:`BatchInFlightError` if a batch is already running.
231        Callers should either await its completion, cancel it, or clear
232        it (if terminal) before starting a new one.
233        """
234        if self._current is not None and not self._current.is_terminal():
235            raise BatchInFlightError(self._loco_id, self._current.batch_id)
236        batch = Batch(
237            batch_id=uuid.uuid4().hex[:12],
238            loco_id=self._loco_id,
239            writes=list(writes),
240            mode=mode,
241            address=address,
242            long_address=long_address,
243            file_name=file_name,
244        )
245        self._current = batch
246        batch.state = BatchState.IN_PROGRESS
247        batch._task = asyncio.create_task(
248            self._run(batch), name=f"batch-{self._loco_id}-{batch.batch_id}",
249        )
250        return batch
251
252    async def _run(self, batch: Batch) -> None:
253        """Iterate ops serially, honouring the cancel flag between each."""
254        try:
255            for op in batch.writes:
256                if batch._cancel.is_set():
257                    op.result = WriteResult.CANCELLED
258                    continue
259                op.result = WriteResult.WRITING
260                extra: dict[str, Any] = {
261                    "mode": batch.mode,
262                    "file_name": batch.file_name,
263                }
264                if batch.address is not None:
265                    extra["address"] = batch.address
266                    extra["long_address"] = batch.long_address
267                try:
268                    if op.kind == "read":
269                        if self._read_callback is None:
270                            raise NotImplementedError(
271                                "read op requested but no read_callback configured",
272                            )
273                        value = await self._read_callback(
274                            op.cv, op.item, extra,
275                        )
276                        # Reads land the byte back into ``value`` so
277                        # the status snapshot's shape stays uniform
278                        # with writes.
279                        op.value = int(value)
280                    else:
281                        await self._write_callback(
282                            op.cv, op.value, op.item, extra,
283                        )
284                except Exception as e:  # noqa: BLE001
285                    op.result = WriteResult.ERROR
286                    op.error = str(e)
287                    _log.warning(
288                        "batch %s: CV %d %s failed: %s",
289                        batch.batch_id, op.cv, op.kind, e,
290                    )
291                    continue
292                op.result = WriteResult.DONE
293            # Terminal state: cancelled if flag was set at any point,
294            # error if all failed, otherwise done.
295            any_pending = any(w.result is WriteResult.PENDING for w in batch.writes)
296            any_cancelled = any(w.result is WriteResult.CANCELLED for w in batch.writes)
297            all_errored = batch.writes and all(
298                w.result is WriteResult.ERROR for w in batch.writes
299            )
300            if batch._cancel.is_set() or any_cancelled or any_pending:
301                batch.state = BatchState.CANCELLED
302            elif all_errored:
303                batch.state = BatchState.ERROR
304                batch.error = "all writes failed"
305            else:
306                batch.state = BatchState.DONE
307        except asyncio.CancelledError:
308            batch.state = BatchState.CANCELLED
309            raise
310        except Exception as e:  # noqa: BLE001
311            batch.state = BatchState.ERROR
312            batch.error = str(e)
313            _log.exception("batch %s crashed", batch.batch_id)
314        finally:
315            batch.finished_at = time.monotonic()

Holds the active Batch for one loco.

A completed batch stays visible on the queue until the caller either starts a new batch (which replaces it) or explicitly clears it. That lets the UI show the final result without racing the completion notification.

read_callback is optional; when omitted, only write batches can run (a read op in the batch will fail with a NotImplementedError).

ProgrammingQueue( loco_id: str, write_callback: Callable[[int, int, str, dict[str, typing.Any]], Awaitable[int]], read_callback: Callable[[int, str, dict[str, typing.Any]], Awaitable[int]] | None = None)
183    def __init__(
184        self, loco_id: str, write_callback: WriteCallback,
185        read_callback: ReadCallback | None = None,
186    ) -> None:
187        self._loco_id = loco_id
188        self._write_callback = write_callback
189        self._read_callback = read_callback
190        self._current: Batch | None = None
loco_id: str
192    @property
193    def loco_id(self) -> str:
194        return self._loco_id
current: Batch | None
196    @property
197    def current(self) -> Batch | None:
198        return self._current
def is_busy(self) -> bool:
200    def is_busy(self) -> bool:
201        return (
202            self._current is not None
203            and self._current.state is BatchState.IN_PROGRESS
204        )
def clear(self) -> None:
206    def clear(self) -> None:
207        """Drop a terminal-state batch. Raises if a batch is still running."""
208        if self._current is not None and not self._current.is_terminal():
209            raise BatchInFlightError(self._loco_id, self._current.batch_id)
210        self._current = None

Drop a terminal-state batch. Raises if a batch is still running.

def cancel(self) -> Batch | None:
212    def cancel(self) -> Batch | None:
213        """Signal cancel on the current batch. Returns it (or None)."""
214        if self._current is None or self._current.is_terminal():
215            return self._current
216        self._current.request_cancel()
217        return self._current

Signal cancel on the current batch. Returns it (or None).

async def start( self, writes: list[BatchWrite], *, mode: str = 'service', address: int | None = None, long_address: bool = False, file_name: str | None = None) -> Batch:
219    async def start(
220        self,
221        writes: list[BatchWrite],
222        *,
223        mode: str = "service",
224        address: int | None = None,
225        long_address: bool = False,
226        file_name: str | None = None,
227    ) -> Batch:
228        """Queue *writes* and start the worker.
229
230        Raises :class:`BatchInFlightError` if a batch is already running.
231        Callers should either await its completion, cancel it, or clear
232        it (if terminal) before starting a new one.
233        """
234        if self._current is not None and not self._current.is_terminal():
235            raise BatchInFlightError(self._loco_id, self._current.batch_id)
236        batch = Batch(
237            batch_id=uuid.uuid4().hex[:12],
238            loco_id=self._loco_id,
239            writes=list(writes),
240            mode=mode,
241            address=address,
242            long_address=long_address,
243            file_name=file_name,
244        )
245        self._current = batch
246        batch.state = BatchState.IN_PROGRESS
247        batch._task = asyncio.create_task(
248            self._run(batch), name=f"batch-{self._loco_id}-{batch.batch_id}",
249        )
250        return batch

Queue writes and start the worker.

Raises BatchInFlightError if a batch is already running. Callers should either await its completion, cancel it, or clear it (if terminal) before starting a new one.

def get_or_create_queue( ctx: Any, loco_id: str, write_callback: Callable[[int, int, str, dict[str, typing.Any]], Awaitable[int]], read_callback: Callable[[int, str, dict[str, typing.Any]], Awaitable[int]] | None = None) -> ProgrammingQueue:
318def get_or_create_queue(
319    ctx: Any, loco_id: str, write_callback: WriteCallback,
320    read_callback: ReadCallback | None = None,
321) -> ProgrammingQueue:
322    """Fetch the queue for *loco_id* off *ctx*, creating on first use.
323
324    The queue registry lives on ``ctx._programming_queues`` (dict). One
325    queue per loco keeps batches scoped so cancelling loco A doesn't
326    stop loco B's operation. When an existing queue is returned and a
327    non-None *read_callback* is supplied, the queue's read callback is
328    updated in place — lets a route that didn't set up reads on first
329    use enable them later without swapping the queue instance.
330    """
331    queues: dict[str, ProgrammingQueue] = getattr(ctx, "_programming_queues", None)
332    if queues is None:
333        queues = {}
334        ctx._programming_queues = queues  # type: ignore[attr-defined]
335    if loco_id not in queues:
336        queues[loco_id] = ProgrammingQueue(
337            loco_id, write_callback, read_callback=read_callback,
338        )
339    elif read_callback is not None and queues[loco_id]._read_callback is None:
340        queues[loco_id]._read_callback = read_callback
341    return queues[loco_id]

Fetch the queue for loco_id off ctx, creating on first use.

The queue registry lives on ctx._programming_queues (dict). One queue per loco keeps batches scoped so cancelling loco A doesn't stop loco B's operation. When an existing queue is returned and a non-None read_callback is supplied, the queue's read callback is updated in place — lets a route that didn't set up reads on first use enable them later without swapping the queue instance.