jmri_loconet.clock

LocoNet fast-clock slot 123 (0x7B) support.

The command station keeps its fast-clock state in slot 123. Reading it is a normal OPC_RQ_SL_DATA for that slot, whose reply is a 14-byte OPC_SL_RD_DATA frame carrying the hours / minutes / fractional-minutes / rate / days encoded across the general slot-record fields.

Byte-layout mapping — cross-checked against JMRI Java's jmri.jmrix.loconet.LocoNetSlot getters (see getFcHours, getFcMinutes, getFcFracMins, getFcRate, getFcDays) and LnClockControl for the roles those fields play:

  • stat → fast-clock rate (0 = stopped, 1..N = running at N×).
  • dirf → minutes, encoded as (255 - 60 - minutes) & 0x7F.
  • ss2 → hours, encoded as (256 - 24 - hours) & 0x7F.
  • addr low 7 bits → fractional-minutes low byte (a 14-bit counter counting down from ~915 within the current minute).
  • addr bits 7..13 → days (7-bit day counter).
  • spd low 7 bits → fractional-minutes high byte.
  • trk bit 0 → track power echo (retain what the CS sent).
  • snd, id1, id2 → unused for slot 123; preserve on write.
  1"""LocoNet fast-clock slot 123 (0x7B) support.
  2
  3The command station keeps its fast-clock state in slot 123. Reading it
  4is a normal ``OPC_RQ_SL_DATA`` for that slot, whose reply is a
  514-byte ``OPC_SL_RD_DATA`` frame carrying the hours / minutes /
  6fractional-minutes / rate / days encoded across the general
  7slot-record fields.
  8
  9Byte-layout mapping — cross-checked against JMRI Java's
 10``jmri.jmrix.loconet.LocoNetSlot`` getters (see ``getFcHours``,
 11``getFcMinutes``, ``getFcFracMins``, ``getFcRate``, ``getFcDays``)
 12and ``LnClockControl`` for the roles those fields play:
 13
 14* ``stat``            → fast-clock rate (0 = stopped, 1..N = running at N×).
 15* ``dirf``            → minutes, encoded as ``(255 - 60 - minutes) & 0x7F``.
 16* ``ss2``             → hours,   encoded as ``(256 - 24 - hours)   & 0x7F``.
 17* ``addr`` low 7 bits → fractional-minutes low byte (a 14-bit
 18                        counter counting down from ~915 within the
 19                        current minute).
 20* ``addr`` bits 7..13 → days (7-bit day counter).
 21* ``spd`` low 7 bits  → fractional-minutes high byte.
 22* ``trk`` bit 0       → track power echo (retain what the CS sent).
 23* ``snd``, ``id1``,
 24  ``id2``             → unused for slot 123; preserve on write.
 25"""
 26from __future__ import annotations
 27
 28from jmri_core.beans.fast_clock import HardwareClockState
 29
 30from jmri_loconet.messages import OpcSlRdData, OpcWrSlData
 31
 32FAST_CLOCK_SLOT = 123
 33
 34# Fractional-minute counter roughly counts down from 915 over the
 35# course of one CS minute. Precise ceiling is CS-firmware-dependent
 36# but 915 is what JMRI Java uses. We only use it to derive the
 37# seconds field of :class:`HardwareClockState`.
 38_FRAC_MIN_MAX = 915
 39
 40
 41def _decode_hours(ss2: int) -> int:
 42    """Decode the hours field from the ``ss2`` byte.
 43
 44    LocoNet's fast-clock convention: hours stored as ``(24 - h) % 24``.
 45    Symmetric on both sides so encode + decode round-trip cleanly.
 46    The exact wire values match JMRI Java's ``LocoNetSlot.setFcHours``
 47    on the write path (0 encodes as 24 & 0x7F = 24, 12 encodes as 12,
 48    23 encodes as 1); JMRI Java's ``getFcHours`` decompiles into a
 49    formula that doesn't cleanly invert its own ``setFcHours`` for
 50    non-canonical hours, so we favour a self-consistent pair here and
 51    match its wire form on encode. Real CS behaviour has always been
 52    the ground truth — a decoded value that displays wrong on a
 53    Digitrax throttle is the signal to revisit."""
 54    return (24 - (ss2 & 0x7F)) % 24
 55
 56
 57def _encode_hours(hours: int) -> int:
 58    return (24 - hours) & 0x7F
 59
 60
 61def _decode_minutes(dirf: int) -> int:
 62    return (60 - (dirf & 0x7F)) % 60
 63
 64
 65def _encode_minutes(minutes: int) -> int:
 66    return (60 - minutes) & 0x7F
 67
 68
 69def _decode_seconds(addr: int, spd: int) -> int:
 70    """Approximate seconds from the fractional-minute counter.
 71
 72    JMRI Java's ``LnClockControl`` uses this same formula (multiplied
 73    by 60000ms) when synchronising the internal clock — we return
 74    integer seconds because :class:`HardwareClockState` doesn't carry
 75    sub-second precision.
 76    """
 77    frac = (addr & 0x7F) | ((spd & 0x7F) << 8)
 78    if frac >= _FRAC_MIN_MAX:
 79        return 0
 80    remainder = (_FRAC_MIN_MAX - frac) / _FRAC_MIN_MAX * 60.0
 81    return max(0, min(59, int(remainder)))
 82
 83
 84def _encode_days_and_frac(days: int, frac: int) -> int:
 85    """Combine the 7-bit days field + 14-bit fractional counter into
 86    the 14-bit ``addr`` value the wire carries. Both are masked."""
 87    return ((days & 0x7F) << 7) | (frac & 0x7F)
 88
 89
 90def _encode_frac_high(frac: int) -> int:
 91    """The high 7 bits of the 14-bit fractional counter land in the
 92    ``spd`` byte."""
 93    return (frac >> 8) & 0x7F
 94
 95
 96def decode_slot123(msg: OpcSlRdData | OpcWrSlData) -> HardwareClockState:
 97    """Turn a slot-123 record into a :class:`HardwareClockState`.
 98
 99    LocoNet doesn't carry a separate "running" flag — the CS reports
100    running as ``rate > 0`` and stopped as ``rate == 0``. We map that
101    into ``HardwareClockState.running`` accordingly. LocoNet's clock
102    is always 24-hour (JMRI Java hardcodes ``twenty_four_hour = True``
103    in :class:`LnClockControl`).
104    """
105    if msg.slot != FAST_CLOCK_SLOT:
106        raise ValueError(
107            f"decode_slot123 expects slot={FAST_CLOCK_SLOT}, got {msg.slot}"
108        )
109    rate_byte = msg.stat & 0x7F
110    return HardwareClockState(
111        hours=_decode_hours(msg.ss2),
112        minutes=_decode_minutes(msg.dirf),
113        seconds=_decode_seconds(msg.address, msg.speed),
114        # LocoNet stores rate as an integer; expose it as a float so
115        # :class:`HardwareClockState` stays uniform across drivers.
116        rate=float(rate_byte),
117        running=rate_byte > 0,
118        twenty_four_hour=True,
119    )
120
121
122def build_slot123_write(
123    state: HardwareClockState,
124    *,
125    days: int = 0,
126    trk: int = 0,
127    snd: int = 0,
128) -> OpcWrSlData:
129    """Turn a :class:`HardwareClockState` into a slot-123 write frame.
130
131    Optional ``days`` / ``trk`` / ``snd`` let callers preserve the
132    non-clock fields from a previous read — the CS keeps track of
133    those and we shouldn't stomp them by writing zeros. Default 0
134    is safe for a first-time initialization.
135
136    ``state.running=False`` writes rate=0 (LocoNet's stopped
137    convention). ``running=True`` with ``rate=0`` writes rate=1 to
138    avoid a "running at 0×" contradiction.
139    """
140    if state.running:
141        rate = int(state.rate) or 1
142    else:
143        rate = 0
144    # Fractional-minute counter — set to zero on write, meaning the
145    # CS treats the current minute as "just started".
146    frac = 0
147    return OpcWrSlData(
148        slot=FAST_CLOCK_SLOT,
149        stat=rate & 0x7F,
150        address=_encode_days_and_frac(days, frac),
151        speed=_encode_frac_high(frac),
152        dirf=_encode_minutes(state.minutes),
153        trk=trk & 0x7F,
154        ss2=_encode_hours(state.hours),
155        snd=snd & 0x7F,
156    )
FAST_CLOCK_SLOT = 123
 97def decode_slot123(msg: OpcSlRdData | OpcWrSlData) -> HardwareClockState:
 98    """Turn a slot-123 record into a :class:`HardwareClockState`.
 99
100    LocoNet doesn't carry a separate "running" flag — the CS reports
101    running as ``rate > 0`` and stopped as ``rate == 0``. We map that
102    into ``HardwareClockState.running`` accordingly. LocoNet's clock
103    is always 24-hour (JMRI Java hardcodes ``twenty_four_hour = True``
104    in :class:`LnClockControl`).
105    """
106    if msg.slot != FAST_CLOCK_SLOT:
107        raise ValueError(
108            f"decode_slot123 expects slot={FAST_CLOCK_SLOT}, got {msg.slot}"
109        )
110    rate_byte = msg.stat & 0x7F
111    return HardwareClockState(
112        hours=_decode_hours(msg.ss2),
113        minutes=_decode_minutes(msg.dirf),
114        seconds=_decode_seconds(msg.address, msg.speed),
115        # LocoNet stores rate as an integer; expose it as a float so
116        # :class:`HardwareClockState` stays uniform across drivers.
117        rate=float(rate_byte),
118        running=rate_byte > 0,
119        twenty_four_hour=True,
120    )

Turn a slot-123 record into a HardwareClockState.

LocoNet doesn't carry a separate "running" flag — the CS reports running as rate > 0 and stopped as rate == 0. We map that into HardwareClockState.running accordingly. LocoNet's clock is always 24-hour (JMRI Java hardcodes twenty_four_hour = True in LnClockControl).

def build_slot123_write( state: jmri_core.beans.HardwareClockState, *, days: int = 0, trk: int = 0, snd: int = 0) -> jmri_loconet.messages.OpcWrSlData:
123def build_slot123_write(
124    state: HardwareClockState,
125    *,
126    days: int = 0,
127    trk: int = 0,
128    snd: int = 0,
129) -> OpcWrSlData:
130    """Turn a :class:`HardwareClockState` into a slot-123 write frame.
131
132    Optional ``days`` / ``trk`` / ``snd`` let callers preserve the
133    non-clock fields from a previous read — the CS keeps track of
134    those and we shouldn't stomp them by writing zeros. Default 0
135    is safe for a first-time initialization.
136
137    ``state.running=False`` writes rate=0 (LocoNet's stopped
138    convention). ``running=True`` with ``rate=0`` writes rate=1 to
139    avoid a "running at 0×" contradiction.
140    """
141    if state.running:
142        rate = int(state.rate) or 1
143    else:
144        rate = 0
145    # Fractional-minute counter — set to zero on write, meaning the
146    # CS treats the current minute as "just started".
147    frac = 0
148    return OpcWrSlData(
149        slot=FAST_CLOCK_SLOT,
150        stat=rate & 0x7F,
151        address=_encode_days_and_frac(days, frac),
152        speed=_encode_frac_high(frac),
153        dirf=_encode_minutes(state.minutes),
154        trk=trk & 0x7F,
155        ss2=_encode_hours(state.hours),
156        snd=snd & 0x7F,
157    )

Turn a HardwareClockState into a slot-123 write frame.

Optional days / trk / snd let callers preserve the non-clock fields from a previous read — the CS keeps track of those and we shouldn't stomp them by writing zeros. Default 0 is safe for a first-time initialization.

state.running=False writes rate=0 (LocoNet's stopped convention). running=True with rate=0 writes rate=1 to avoid a "running at 0×" contradiction.