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.addrlow 7 bits → fractional-minutes low byte (a 14-bit counter counting down from ~915 within the current minute).addrbits 7..13 → days (7-bit day counter).spdlow 7 bits → fractional-minutes high byte.trkbit 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 )
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).
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.