Source code for cubemarspycan.latch

"""Publication boxes between the receive thread and the control thread.

This is the only concurrency-critical module in the library, which is why it is a module
rather than a few lines inside the motor class. It is about sixty lines and carries a
thread-stress test.

The invariant that makes it correct is a *type* property enforced elsewhere: every state
class is ``frozen=True, slots=True``, so the writer must construct a new value per frame
and can never mutate one it has already published. Handing a reference back to the reader
is therefore semantically a copy, and tearing is impossible rather than merely avoided.

For contrast, TMotorCANControl's ``mit_can.py:774`` copies field-by-field out of an object
the receive thread is concurrently mutating, so a caller can observe position from frame N
beside velocity from frame N+1; and ``servo_serial.py:783`` rebinds the name instead of
copying, collapsing its double buffer entirely after the first update.
"""

from __future__ import annotations

import threading
from typing import Generic, TypeVar

from .state import FaultEvent

S = TypeVar("S")


[docs] class StateLatch(Generic[S]): """Single-writer, single-reader publication of an immutable value. The lock is held only for three attribute assignments: no allocation, no logging and no I/O happen inside it, so the receive thread never blocks the control thread for longer than a few hundred nanoseconds. """ __slots__ = ("_lock", "_rx_t", "_seq", "_value") def __init__(self) -> None: self._lock = threading.Lock() self._value: S | None = None self._rx_t: float = 0.0 self._seq: int = 0
[docs] def publish(self, value: S, rx_monotonic: float) -> None: """Called from the receive thread. Must never raise.""" with self._lock: self._value = value self._rx_t = rx_monotonic self._seq += 1
[docs] def read(self) -> tuple[S | None, float, int]: """Called from the control thread. Returns ``(value, rx_monotonic, seq)``. The three move together, so a caller can always tell whether the value it is holding is the one whose timestamp it just checked. """ with self._lock: return self._value, self._rx_t, self._seq
@property def seq(self) -> int: """Number of values published so far. Monotonic.""" with self._lock: return self._seq
[docs] def reset(self) -> None: """Drop the published value, returning the latch to its never-written state. For the control thread between runs. Does not reset the sequence number, so a reader cannot mistake a reset for a fresh frame. """ with self._lock: self._value = None self._rx_t = 0.0 self._seq = 0
[docs] class FaultLatch: """Sticky fault storage. Set from the receive thread, consumed from the control thread. Nothing here raises. A fault stays *data* until ``update()`` decides, on the caller's thread, whether it becomes control flow. That is the fix for the most safety-relevant defect in the reference library, where a fault raised inside the python-can notifier thread never reached the control loop and the motor kept being commanded. """ __slots__ = ("_count", "_event", "_lock", "_unseen") def __init__(self) -> None: self._lock = threading.Lock() self._event: FaultEvent | None = None self._count: int = 0 self._unseen: bool = False
[docs] def set(self, event: FaultEvent) -> None: """Latch a fault. The first one wins; later ones only bump the counter. Keeping the first is deliberate: a fault often cascades (an over-current trips, which stalls the motor, which trips over-temperature), and the first code is the one that tells you what actually happened. """ with self._lock: self._count += 1 if self._event is None: self._event = event self._unseen = True
[docs] def peek(self) -> FaultEvent | None: """The latched fault, without consuming it.""" with self._lock: return self._event
[docs] def take_new(self) -> FaultEvent | None: """The latched fault if it has not been reported yet, else ``None``. Lets ``update()`` raise once per fault instead of on every call. """ with self._lock: if self._event is not None and self._unseen: self._unseen = False return self._event return None
[docs] def clear(self) -> None: """Forget the fault. Explicit, and only from the control thread.""" with self._lock: self._event = None self._count = 0 self._unseen = False
@property def count(self) -> int: """How many faulted frames have arrived since the last clear.""" with self._lock: return self._count @property def faulted(self) -> bool: """Whether a fault is currently latched. Safe from either thread, and unlike :meth:`take_new` it does not consume the event: this answers "is it faulted", not "is there news". """ with self._lock: return self._event is not None