jmri_withrottle.protocol
WiThrottle line protocol — parsing + encoding.
WiThrottle is a line-based ASCII protocol on TCP. Newline-terminated messages flow in both directions, with mostly self-describing first characters:
VN— protocol versionHT— server (railroad) nameRL— roster listPPA— track power:PPA1on,PPA0offPFT— fast clockM{T,S}…— throttle multiplex commands;Tis the primary throttle,Sis the secondary on the same client*— heartbeat tick from clientQ— quit
We implement enough of the spec to drive a loco from Engine Driver: acquire/release a throttle, set speed/direction/functions, e-stop, heartbeat. The fancier bits (consist building, signal cues, turnout/route control over WiThrottle, panel data, fast-clock sync) land in follow-up sprints.
References:
- https://www.jmri.org/help/en/package/jmri/jmrit/withrottle/Protocol.shtml
- Engine Driver's source (Android) for real-world client behaviour.
1"""WiThrottle line protocol — parsing + encoding. 2 3WiThrottle is a line-based ASCII protocol on TCP. Newline-terminated 4messages flow in both directions, with mostly self-describing first 5characters: 6 7* ``VN`` — protocol version 8* ``HT`` — server (railroad) name 9* ``RL`` — roster list 10* ``PPA`` — track power: ``PPA1`` on, ``PPA0`` off 11* ``PFT`` — fast clock 12* ``M{T,S}…`` — throttle multiplex commands; ``T`` is the primary 13 throttle, ``S`` is the secondary on the same client 14* ``*`` — heartbeat tick from client 15* ``Q`` — quit 16 17We implement enough of the spec to drive a loco from Engine Driver: 18acquire/release a throttle, set speed/direction/functions, e-stop, 19heartbeat. The fancier bits (consist building, signal cues, 20turnout/route control over WiThrottle, panel data, fast-clock sync) 21land in follow-up sprints. 22 23References: 24* https://www.jmri.org/help/en/package/jmri/jmrit/withrottle/Protocol.shtml 25* Engine Driver's source (Android) for real-world client behaviour. 26""" 27from __future__ import annotations 28 29from dataclasses import dataclass 30 31 32# Default heartbeat interval the server requests of clients. Engine 33# Driver's default poll interval matches this. 34DEFAULT_HEARTBEAT_S = 10 35 36# Maximum function number we acknowledge. WiThrottle supports F0..F28. 37MAX_FUNCTION = 28 38 39# Default function labels sent when no roster labels are supplied. 40# Engine Driver's manual-address acquire path uses its *own* client-side 41# defaults ("Light", "Bell", "Horn" for F0..F2, generic Fn thereafter); 42# roster-based acquires take the labels from the wire. Matching those 43# three keeps both acquire paths cosmetically consistent. 44_DEFAULT_FUNCTION_LABELS = ( 45 ["Light", "Bell", "Horn"] 46 + [f"F{i}" for i in range(3, MAX_FUNCTION + 1)] 47) 48 49 50# ----- typed inbound messages ---------------------------------------------- 51 52 53@dataclass(slots=True) 54class HelloName: 55 """Client sent its display name. ``N<name>``.""" 56 name: str 57 58 59@dataclass(slots=True) 60class HelloHardwareId: 61 """Client sent its hardware id. ``HU<id>``.""" 62 hardware_id: str 63 64 65@dataclass(slots=True) 66class Heartbeat: 67 """Client sent the periodic ``*`` tick.""" 68 69 70@dataclass(slots=True) 71class Quit: 72 """Client is disconnecting (``Q``).""" 73 74 75@dataclass(slots=True) 76class AcquireLoco: 77 """Client requested a loco on a throttle (``M{T,S}+<addr><;><label>``). 78 79 *throttle* is the single-character throttle id (typically ``T`` 80 or ``S``). *address_token* is the raw address string from the wire, 81 e.g. ``L3601`` (long 3601) or ``S3`` (short 3) or ``3601`` (legacy). 82 """ 83 throttle: str 84 address_token: str 85 label: str 86 87 88@dataclass(slots=True) 89class ReleaseLoco: 90 """Client released a loco (``M{T,S}-<addr><;>``).""" 91 throttle: str 92 address_token: str 93 94 95@dataclass(slots=True) 96class ThrottleAction: 97 """A V/R/F/X action on an acquired loco. 98 99 ``M{T,S}A<address_token><;><action><value>``: 100 101 * ``V<0..126>`` — set speed (126 = full, 1 = e-stop sentinel, 0 = stop) 102 * ``R<0|1>`` — direction (1 = forward) 103 * ``F<state><fn>`` — function: state 1 = press, 0 = release 104 * ``f<state><fn>`` — function force-state (set explicitly) 105 * ``X`` — e-stop 106 * ``I`` — idle (speed 0) 107 * ``L`` — set speed steps; we ignore for alpha 108 """ 109 throttle: str 110 address_token: str 111 action: str # one of: "V", "R", "F", "f", "X", "I", "L" 112 value: str # remainder of the action; may be empty (e.g. for "X") 113 114 115@dataclass(slots=True) 116class TurnoutAction: 117 """Client asked us to change a turnout: ``PTA<action><sysName>``. 118 119 * ``action == "T"`` — throw 120 * ``action == "C"`` — close 121 * ``action == "2"`` — toggle 122 """ 123 action: str # "T" | "C" | "2" 124 system_name: str 125 126 127@dataclass(slots=True) 128class Unknown: 129 """An inbound line we don't recognise — preserve it for logging.""" 130 raw: str 131 132 133# ----- turnout state codes (match jmri.Turnout Java constants) ------------- 134 135TURNOUT_UNKNOWN = 1 136TURNOUT_CLOSED = 2 137TURNOUT_THROWN = 4 138TURNOUT_INCONSISTENT = 8 139 140 141# ----- decoder ------------------------------------------------------------- 142 143 144def decode_line(line: str) -> object: 145 """Parse one WiThrottle inbound line. Returns one of the typed messages.""" 146 line = line.rstrip("\r\n") 147 if not line: 148 return Unknown(raw="") 149 head = line[0] 150 if head == "*": 151 return Heartbeat() 152 if head == "Q": 153 return Quit() 154 if head == "N" and len(line) > 1: 155 return HelloName(name=line[1:]) 156 if head == "H" and line.startswith("HU"): 157 return HelloHardwareId(hardware_id=line[2:]) 158 if head == "M": 159 return _decode_throttle(line) 160 if line.startswith("PTA") and len(line) >= 4: 161 return TurnoutAction(action=line[3], system_name=line[4:]) 162 return Unknown(raw=line) 163 164 165def _decode_throttle(line: str) -> object: 166 """``M{T,S}<op><addr><;><payload>``.""" 167 if len(line) < 4: 168 return Unknown(raw=line) 169 throttle = line[1] 170 op = line[2] 171 rest = line[3:] 172 # The wire uses "<;>" (the literal three characters) as a separator. 173 sep = "<;>" 174 if sep in rest: 175 addr_token, payload = rest.split(sep, 1) 176 else: 177 addr_token, payload = rest, "" 178 if op == "+": 179 return AcquireLoco(throttle=throttle, address_token=addr_token, label=payload) 180 if op == "-": 181 return ReleaseLoco(throttle=throttle, address_token=addr_token) 182 if op == "A": 183 if not payload: 184 return Unknown(raw=line) 185 action = payload[0] 186 value = payload[1:] 187 return ThrottleAction( 188 throttle=throttle, address_token=addr_token, 189 action=action, value=value, 190 ) 191 return Unknown(raw=line) 192 193 194# ----- address-token parsing ---------------------------------------------- 195 196 197def parse_address_token(token: str) -> tuple[int, bool]: 198 """Decode WiThrottle's address-token format. 199 200 * ``L<n>`` — explicit long address ``n`` 201 * ``S<n>`` — explicit short address ``n`` 202 * bare digits — NMRA convention: addresses ``1..127`` are short, 203 ``≥128`` are long. Older clients (and some legacy code paths in 204 Engine Driver itself) drop the prefix; matching JMRI's behaviour, 205 we infer the type from the numeric range so high-address locos 206 get the correct DCC encoding. 207 208 Returns ``(address, is_long)``. 209 """ 210 if not token: 211 raise ValueError("empty address token") 212 if token[0] in ("L", "l"): 213 return int(token[1:]), True 214 if token[0] in ("S", "s"): 215 return int(token[1:]), False 216 addr = int(token) 217 # NMRA: short DCC range is 1..127; anything higher must be long. 218 return addr, addr >= 128 219 220 221def format_address_token(address: int, long_address: bool) -> str: 222 """Encode an address as a WiThrottle wire token.""" 223 return f"{'L' if long_address else 'S'}{address}" 224 225 226# ----- encoders (server → client) ----------------------------------------- 227 228 229def greeting(*, 230 name: str = "Python JMRI", 231 heartbeat_s: int = DEFAULT_HEARTBEAT_S, 232 web_port: int = 8000, 233) -> list[str]: 234 """Initial handshake lines a server sends right after accept.""" 235 return [ 236 "VN2.0", # protocol version 2.0 237 f"HT{name}", # railroad name 238 f"HtPython JMRI {name}", # display title 239 f"PW{web_port}", # JSON server port (optional advertisement) 240 f"*{heartbeat_s}", # request heartbeat at this cadence 241 "PPA0", # initial power state (clients refresh later) 242 ] 243 244 245def power_state(on: bool) -> str: 246 return f"PPA{1 if on else 0}" 247 248 249def roster_list(entries: list[dict]) -> str: 250 """``RL<count>]\\[<id><}|><address>]\\[<long|short>]\\[<id>...``. 251 252 *entries* are roster-service dicts (id, dccAddress, longAddress). 253 """ 254 parts: list[str] = [f"RL{len(entries)}"] 255 for e in entries: 256 eid = str(e.get("id") or "") 257 addr = str(e.get("dccAddress") or "") 258 long = "L" if e.get("longAddress") else "S" 259 # Wire form per JMRI's WiThrottle docs. 260 parts.append(f"{eid}}}|{{{addr}}}|{{{long}") 261 # JMRI uses "]\\[" (the literal three characters) between entries. 262 return "]\\[".join(parts) 263 264 265def throttle_acquired(throttle: str, address_token: str) -> str: 266 """Acknowledge an M+ request.""" 267 return f"M{throttle}+{address_token}<;>" 268 269 270def throttle_released(throttle: str, address_token: str) -> str: 271 return f"M{throttle}-{address_token}<;>" 272 273 274def throttle_speed(throttle: str, address_token: str, speed: int) -> str: 275 return f"M{throttle}A{address_token}<;>V{speed}" 276 277 278def throttle_direction(throttle: str, address_token: str, forward: bool) -> str: 279 return f"M{throttle}A{address_token}<;>R{1 if forward else 0}" 280 281 282def throttle_function(throttle: str, address_token: str, fn: int, on: bool) -> str: 283 """``F<state><fn>`` — note state is *before* the function number.""" 284 return f"M{throttle}A{address_token}<;>F{1 if on else 0}{fn}" 285 286 287def throttle_speed_step_mode( 288 throttle: str, address_token: str, mode: int = 1, 289) -> str: 290 """``M<mux>A<addr><;>s<mode>`` — 1=128, 2=28, 4=27, 8=14, 16=Motorola. 291 292 JMRI's ``MultiThrottleController.encodeSpeedStepMode``. Sent after 293 the function states so Engine Driver / iOS know how to scale the 294 slider (128-step by default on modern DCC). 295 """ 296 return f"M{throttle}A{address_token}<;>s{mode}" 297 298 299def throttle_function_labels( 300 throttle: str, address_token: str, labels: list[str] | None = None, 301) -> str: 302 """``M<mux>L<addr><;>]\\[label0]\\[label1]...]\\[label28]``. 303 304 JMRI's ``MultiThrottleController.sendFunctionLabels`` — this is the 305 line Engine Driver and WiThrottle iOS need after acquire so they can 306 render the F0..F28 button bar. 307 308 The label values matter more than they look: 309 310 * **Empty** labels are interpreted by Engine Driver as "no function 311 configured" and the bar stays hidden. 312 * **Missing line** works only for manual (``L<addr>``) acquires; for 313 roster (``E<id>``) acquires the client expects labels and hides 314 the bar when none arrive. 315 316 So we always send the line with non-empty defaults (``F0``..``F28``). 317 Pass explicit *labels* when you have roster data — anything falsy in 318 the list (empty string, ``None``) falls back to the default for that 319 slot. 320 """ 321 if labels is None: 322 wire_labels = list(_DEFAULT_FUNCTION_LABELS) 323 else: 324 # Merge caller-supplied labels over defaults; empty slots keep 325 # the default so the client always sees something. 326 wire_labels = list(_DEFAULT_FUNCTION_LABELS) 327 for i, lbl in enumerate(labels[: MAX_FUNCTION + 1]): 328 if lbl: 329 wire_labels[i] = lbl 330 return f"M{throttle}L{address_token}<;>" + "".join( 331 f"]\\[{lbl}" for lbl in wire_labels 332 ) 333 334 335def fast_clock(local_unix_seconds: int, rate: float) -> str: 336 """``PFT<local-unix-seconds><;><rate>``. 337 338 JMRI's ``FastClockController.sendFastTimeAndRate`` sends 339 ``(UTC_millis + TZ_offset) / 1000`` — the fast-clock time as if 340 it were a unix timestamp *in local time* (i.e. UTC unix seconds 341 biased by the local timezone offset so ``client_hour == local_hour`` 342 when the client renders it as UTC). 343 344 A rate of ``0.0`` is the "stopped" signal — JMRI sends a second 345 ``PFT<time><;>0.0`` after the normal broadcast when the clock is 346 held; Engine Driver / WiThrottle iOS use that to pause their own 347 display. 348 """ 349 return f"PFT{int(local_unix_seconds)}<;>{rate}" 350 351 352def turnout_titles() -> str: 353 """``PTT`` — turnout state-label header. 354 355 Sent once per session, before the turnout list. Tells the client 356 which display labels to attach to each numeric state code so the 357 UI can render "Closed"/"Thrown" (or a translation) instead of 358 raw digits. 359 360 Format matches JMRI's ``TurnoutController.sendTitles``: 361 ``PTT]\\[<menu-title>}|{Turnout]\\[Closed}|{2]\\[Thrown}|{4]\\[Unknown}|{1]\\[Inconsistent}|{8`` 362 """ 363 return ( 364 "PTT" 365 + "]\\[Turnouts}|{Turnout" 366 + f"]\\[Closed}}|{{{TURNOUT_CLOSED}" 367 + f"]\\[Thrown}}|{{{TURNOUT_THROWN}" 368 + f"]\\[Unknown}}|{{{TURNOUT_UNKNOWN}" 369 + f"]\\[Inconsistent}}|{{{TURNOUT_INCONSISTENT}" 370 ) 371 372 373def turnout_list(entries: list[dict]) -> str: 374 """``PTL]\\[<sysName>}|{<userName>}|{<state>]\\[...`` 375 376 JMRI's ``TurnoutController.sendList``. Each entry is a dict with: 377 378 * ``system_name`` — required, e.g. ``NT12`` 379 * ``user_name`` — optional friendly label; empty string if absent 380 * ``state`` — one of :data:`TURNOUT_UNKNOWN/CLOSED/THROWN/INCONSISTENT` 381 """ 382 parts: list[str] = ["PTL"] 383 for e in entries: 384 sys_name = str(e.get("system_name") or "") 385 user_name = str(e.get("user_name") or "") 386 state = int(e.get("state") or TURNOUT_UNKNOWN) 387 parts.append(f"]\\[{sys_name}}}|{{{user_name}}}|{{{state}") 388 return "".join(parts) 389 390 391def turnout_state(system_name: str, state: int) -> str: 392 """``PTA<state><sysName>`` — broadcast a turnout state change to the client. 393 394 JMRI's ``TurnoutController.sendTurnoutState``. Called whenever a 395 turnout's ``KnownState`` changes — either from a client action, a 396 layout cab, or an internal script. 397 """ 398 return f"PTA{state}{system_name}" 399 400 401def fast_clock_local_unix_seconds( 402 hours: int, minutes: int, seconds: int = 0, 403 *, now: object = None, 404) -> int: 405 """Convert an ``HH:MM:SS`` fast-clock time to the local-unix value 406 JMRI's ``PFT`` payload expects. 407 408 We fold today's date onto the given HH:MM:SS to build a full 409 datetime, then bias UTC unix seconds by the current timezone offset 410 (matching JMRI's ``(fastClockTime + timeZoneOffset) / 1000L``). 411 """ 412 import datetime # noqa: PLC0415 413 import time as _time # noqa: PLC0415 414 ref = now if isinstance(now, datetime.datetime) else datetime.datetime.now() 415 ref = ref.replace(hour=hours, minute=minutes, second=seconds, microsecond=0) 416 utc_unix = ref.timestamp() 417 # `time.timezone` is seconds west of UTC (positive); we want the 418 # opposite sign. `altzone` applies when DST is in effect. 419 if _time.localtime(utc_unix).tm_isdst > 0 and _time.daylight: 420 offset = -_time.altzone 421 else: 422 offset = -_time.timezone 423 return int(utc_unix + offset)
54@dataclass(slots=True) 55class HelloName: 56 """Client sent its display name. ``N<name>``.""" 57 name: str
Client sent its display name. N<name>.
60@dataclass(slots=True) 61class HelloHardwareId: 62 """Client sent its hardware id. ``HU<id>``.""" 63 hardware_id: str
Client sent its hardware id. HU<id>.
Client sent the periodic * tick.
Client is disconnecting (Q).
76@dataclass(slots=True) 77class AcquireLoco: 78 """Client requested a loco on a throttle (``M{T,S}+<addr><;><label>``). 79 80 *throttle* is the single-character throttle id (typically ``T`` 81 or ``S``). *address_token* is the raw address string from the wire, 82 e.g. ``L3601`` (long 3601) or ``S3`` (short 3) or ``3601`` (legacy). 83 """ 84 throttle: str 85 address_token: str 86 label: str
Client requested a loco on a throttle (M{T,S}+<addr><;><label>).
throttle is the single-character throttle id (typically T
or S). address_token is the raw address string from the wire,
e.g. L3601 (long 3601) or S3 (short 3) or 3601 (legacy).
89@dataclass(slots=True) 90class ReleaseLoco: 91 """Client released a loco (``M{T,S}-<addr><;>``).""" 92 throttle: str 93 address_token: str
Client released a loco (M{T,S}-<addr><;>).
96@dataclass(slots=True) 97class ThrottleAction: 98 """A V/R/F/X action on an acquired loco. 99 100 ``M{T,S}A<address_token><;><action><value>``: 101 102 * ``V<0..126>`` — set speed (126 = full, 1 = e-stop sentinel, 0 = stop) 103 * ``R<0|1>`` — direction (1 = forward) 104 * ``F<state><fn>`` — function: state 1 = press, 0 = release 105 * ``f<state><fn>`` — function force-state (set explicitly) 106 * ``X`` — e-stop 107 * ``I`` — idle (speed 0) 108 * ``L`` — set speed steps; we ignore for alpha 109 """ 110 throttle: str 111 address_token: str 112 action: str # one of: "V", "R", "F", "f", "X", "I", "L" 113 value: str # remainder of the action; may be empty (e.g. for "X")
A V/R/F/X action on an acquired loco.
M{T,S}A<address_token><;><action><value>:
V<0..126>— set speed (126 = full, 1 = e-stop sentinel, 0 = stop)R<0|1>— direction (1 = forward)F<state><fn>— function: state 1 = press, 0 = releasef<state><fn>— function force-state (set explicitly)X— e-stopI— idle (speed 0)L— set speed steps; we ignore for alpha
116@dataclass(slots=True) 117class TurnoutAction: 118 """Client asked us to change a turnout: ``PTA<action><sysName>``. 119 120 * ``action == "T"`` — throw 121 * ``action == "C"`` — close 122 * ``action == "2"`` — toggle 123 """ 124 action: str # "T" | "C" | "2" 125 system_name: str
Client asked us to change a turnout: PTA<action><sysName>.
action == "T"— throwaction == "C"— closeaction == "2"— toggle
128@dataclass(slots=True) 129class Unknown: 130 """An inbound line we don't recognise — preserve it for logging.""" 131 raw: str
An inbound line we don't recognise — preserve it for logging.
145def decode_line(line: str) -> object: 146 """Parse one WiThrottle inbound line. Returns one of the typed messages.""" 147 line = line.rstrip("\r\n") 148 if not line: 149 return Unknown(raw="") 150 head = line[0] 151 if head == "*": 152 return Heartbeat() 153 if head == "Q": 154 return Quit() 155 if head == "N" and len(line) > 1: 156 return HelloName(name=line[1:]) 157 if head == "H" and line.startswith("HU"): 158 return HelloHardwareId(hardware_id=line[2:]) 159 if head == "M": 160 return _decode_throttle(line) 161 if line.startswith("PTA") and len(line) >= 4: 162 return TurnoutAction(action=line[3], system_name=line[4:]) 163 return Unknown(raw=line)
Parse one WiThrottle inbound line. Returns one of the typed messages.
198def parse_address_token(token: str) -> tuple[int, bool]: 199 """Decode WiThrottle's address-token format. 200 201 * ``L<n>`` — explicit long address ``n`` 202 * ``S<n>`` — explicit short address ``n`` 203 * bare digits — NMRA convention: addresses ``1..127`` are short, 204 ``≥128`` are long. Older clients (and some legacy code paths in 205 Engine Driver itself) drop the prefix; matching JMRI's behaviour, 206 we infer the type from the numeric range so high-address locos 207 get the correct DCC encoding. 208 209 Returns ``(address, is_long)``. 210 """ 211 if not token: 212 raise ValueError("empty address token") 213 if token[0] in ("L", "l"): 214 return int(token[1:]), True 215 if token[0] in ("S", "s"): 216 return int(token[1:]), False 217 addr = int(token) 218 # NMRA: short DCC range is 1..127; anything higher must be long. 219 return addr, addr >= 128
Decode WiThrottle's address-token format.
L<n>— explicit long addressnS<n>— explicit short addressn- bare digits — NMRA convention: addresses
1..127are short,≥128are long. Older clients (and some legacy code paths in Engine Driver itself) drop the prefix; matching JMRI's behaviour, we infer the type from the numeric range so high-address locos get the correct DCC encoding.
Returns (address, is_long).
222def format_address_token(address: int, long_address: bool) -> str: 223 """Encode an address as a WiThrottle wire token.""" 224 return f"{'L' if long_address else 'S'}{address}"
Encode an address as a WiThrottle wire token.
230def greeting(*, 231 name: str = "Python JMRI", 232 heartbeat_s: int = DEFAULT_HEARTBEAT_S, 233 web_port: int = 8000, 234) -> list[str]: 235 """Initial handshake lines a server sends right after accept.""" 236 return [ 237 "VN2.0", # protocol version 2.0 238 f"HT{name}", # railroad name 239 f"HtPython JMRI {name}", # display title 240 f"PW{web_port}", # JSON server port (optional advertisement) 241 f"*{heartbeat_s}", # request heartbeat at this cadence 242 "PPA0", # initial power state (clients refresh later) 243 ]
Initial handshake lines a server sends right after accept.
250def roster_list(entries: list[dict]) -> str: 251 """``RL<count>]\\[<id><}|><address>]\\[<long|short>]\\[<id>...``. 252 253 *entries* are roster-service dicts (id, dccAddress, longAddress). 254 """ 255 parts: list[str] = [f"RL{len(entries)}"] 256 for e in entries: 257 eid = str(e.get("id") or "") 258 addr = str(e.get("dccAddress") or "") 259 long = "L" if e.get("longAddress") else "S" 260 # Wire form per JMRI's WiThrottle docs. 261 parts.append(f"{eid}}}|{{{addr}}}|{{{long}") 262 # JMRI uses "]\\[" (the literal three characters) between entries. 263 return "]\\[".join(parts)
RL<count>]\[<id><}|><address>]\[<long|short>]\[<id>....
entries are roster-service dicts (id, dccAddress, longAddress).
266def throttle_acquired(throttle: str, address_token: str) -> str: 267 """Acknowledge an M+ request.""" 268 return f"M{throttle}+{address_token}<;>"
Acknowledge an M+ request.
283def throttle_function(throttle: str, address_token: str, fn: int, on: bool) -> str: 284 """``F<state><fn>`` — note state is *before* the function number.""" 285 return f"M{throttle}A{address_token}<;>F{1 if on else 0}{fn}"
F<state><fn> — note state is before the function number.
288def throttle_speed_step_mode( 289 throttle: str, address_token: str, mode: int = 1, 290) -> str: 291 """``M<mux>A<addr><;>s<mode>`` — 1=128, 2=28, 4=27, 8=14, 16=Motorola. 292 293 JMRI's ``MultiThrottleController.encodeSpeedStepMode``. Sent after 294 the function states so Engine Driver / iOS know how to scale the 295 slider (128-step by default on modern DCC). 296 """ 297 return f"M{throttle}A{address_token}<;>s{mode}"
M<mux>A<addr><;>s<mode> — 1=128, 2=28, 4=27, 8=14, 16=Motorola.
JMRI's MultiThrottleController.encodeSpeedStepMode. Sent after
the function states so Engine Driver / iOS know how to scale the
slider (128-step by default on modern DCC).
300def throttle_function_labels( 301 throttle: str, address_token: str, labels: list[str] | None = None, 302) -> str: 303 """``M<mux>L<addr><;>]\\[label0]\\[label1]...]\\[label28]``. 304 305 JMRI's ``MultiThrottleController.sendFunctionLabels`` — this is the 306 line Engine Driver and WiThrottle iOS need after acquire so they can 307 render the F0..F28 button bar. 308 309 The label values matter more than they look: 310 311 * **Empty** labels are interpreted by Engine Driver as "no function 312 configured" and the bar stays hidden. 313 * **Missing line** works only for manual (``L<addr>``) acquires; for 314 roster (``E<id>``) acquires the client expects labels and hides 315 the bar when none arrive. 316 317 So we always send the line with non-empty defaults (``F0``..``F28``). 318 Pass explicit *labels* when you have roster data — anything falsy in 319 the list (empty string, ``None``) falls back to the default for that 320 slot. 321 """ 322 if labels is None: 323 wire_labels = list(_DEFAULT_FUNCTION_LABELS) 324 else: 325 # Merge caller-supplied labels over defaults; empty slots keep 326 # the default so the client always sees something. 327 wire_labels = list(_DEFAULT_FUNCTION_LABELS) 328 for i, lbl in enumerate(labels[: MAX_FUNCTION + 1]): 329 if lbl: 330 wire_labels[i] = lbl 331 return f"M{throttle}L{address_token}<;>" + "".join( 332 f"]\\[{lbl}" for lbl in wire_labels 333 )
M<mux>L<addr><;>]\[label0]\[label1]...]\[label28].
JMRI's MultiThrottleController.sendFunctionLabels — this is the
line Engine Driver and WiThrottle iOS need after acquire so they can
render the F0..F28 button bar.
The label values matter more than they look:
- Empty labels are interpreted by Engine Driver as "no function configured" and the bar stays hidden.
- Missing line works only for manual (
L<addr>) acquires; for roster (E<id>) acquires the client expects labels and hides the bar when none arrive.
So we always send the line with non-empty defaults (F0..F28).
Pass explicit labels when you have roster data — anything falsy in
the list (empty string, None) falls back to the default for that
slot.
336def fast_clock(local_unix_seconds: int, rate: float) -> str: 337 """``PFT<local-unix-seconds><;><rate>``. 338 339 JMRI's ``FastClockController.sendFastTimeAndRate`` sends 340 ``(UTC_millis + TZ_offset) / 1000`` — the fast-clock time as if 341 it were a unix timestamp *in local time* (i.e. UTC unix seconds 342 biased by the local timezone offset so ``client_hour == local_hour`` 343 when the client renders it as UTC). 344 345 A rate of ``0.0`` is the "stopped" signal — JMRI sends a second 346 ``PFT<time><;>0.0`` after the normal broadcast when the clock is 347 held; Engine Driver / WiThrottle iOS use that to pause their own 348 display. 349 """ 350 return f"PFT{int(local_unix_seconds)}<;>{rate}"
PFT<local-unix-seconds><;><rate>.
JMRI's FastClockController.sendFastTimeAndRate sends
(UTC_millis + TZ_offset) / 1000 — the fast-clock time as if
it were a unix timestamp in local time (i.e. UTC unix seconds
biased by the local timezone offset so client_hour == local_hour
when the client renders it as UTC).
A rate of 0.0 is the "stopped" signal — JMRI sends a second
PFT<time><;>0.0 after the normal broadcast when the clock is
held; Engine Driver / WiThrottle iOS use that to pause their own
display.
353def turnout_titles() -> str: 354 """``PTT`` — turnout state-label header. 355 356 Sent once per session, before the turnout list. Tells the client 357 which display labels to attach to each numeric state code so the 358 UI can render "Closed"/"Thrown" (or a translation) instead of 359 raw digits. 360 361 Format matches JMRI's ``TurnoutController.sendTitles``: 362 ``PTT]\\[<menu-title>}|{Turnout]\\[Closed}|{2]\\[Thrown}|{4]\\[Unknown}|{1]\\[Inconsistent}|{8`` 363 """ 364 return ( 365 "PTT" 366 + "]\\[Turnouts}|{Turnout" 367 + f"]\\[Closed}}|{{{TURNOUT_CLOSED}" 368 + f"]\\[Thrown}}|{{{TURNOUT_THROWN}" 369 + f"]\\[Unknown}}|{{{TURNOUT_UNKNOWN}" 370 + f"]\\[Inconsistent}}|{{{TURNOUT_INCONSISTENT}" 371 )
PTT — turnout state-label header.
Sent once per session, before the turnout list. Tells the client which display labels to attach to each numeric state code so the UI can render "Closed"/"Thrown" (or a translation) instead of raw digits.
Format matches JMRI's TurnoutController.sendTitles:
PTT]\[<menu-title>}|{Turnout]\[Closed}|{2]\[Thrown}|{4]\[Unknown}|{1]\[Inconsistent}|{8
374def turnout_list(entries: list[dict]) -> str: 375 """``PTL]\\[<sysName>}|{<userName>}|{<state>]\\[...`` 376 377 JMRI's ``TurnoutController.sendList``. Each entry is a dict with: 378 379 * ``system_name`` — required, e.g. ``NT12`` 380 * ``user_name`` — optional friendly label; empty string if absent 381 * ``state`` — one of :data:`TURNOUT_UNKNOWN/CLOSED/THROWN/INCONSISTENT` 382 """ 383 parts: list[str] = ["PTL"] 384 for e in entries: 385 sys_name = str(e.get("system_name") or "") 386 user_name = str(e.get("user_name") or "") 387 state = int(e.get("state") or TURNOUT_UNKNOWN) 388 parts.append(f"]\\[{sys_name}}}|{{{user_name}}}|{{{state}") 389 return "".join(parts)
PTL]\[<sysName>}|{<userName>}|{<state>]\[...
JMRI's TurnoutController.sendList. Each entry is a dict with:
system_name— required, e.g.NT12user_name— optional friendly label; empty string if absentstate— one ofTURNOUT_UNKNOWN/CLOSED/THROWN/INCONSISTENT
392def turnout_state(system_name: str, state: int) -> str: 393 """``PTA<state><sysName>`` — broadcast a turnout state change to the client. 394 395 JMRI's ``TurnoutController.sendTurnoutState``. Called whenever a 396 turnout's ``KnownState`` changes — either from a client action, a 397 layout cab, or an internal script. 398 """ 399 return f"PTA{state}{system_name}"
PTA<state><sysName> — broadcast a turnout state change to the client.
JMRI's TurnoutController.sendTurnoutState. Called whenever a
turnout's KnownState changes — either from a client action, a
layout cab, or an internal script.
402def fast_clock_local_unix_seconds( 403 hours: int, minutes: int, seconds: int = 0, 404 *, now: object = None, 405) -> int: 406 """Convert an ``HH:MM:SS`` fast-clock time to the local-unix value 407 JMRI's ``PFT`` payload expects. 408 409 We fold today's date onto the given HH:MM:SS to build a full 410 datetime, then bias UTC unix seconds by the current timezone offset 411 (matching JMRI's ``(fastClockTime + timeZoneOffset) / 1000L``). 412 """ 413 import datetime # noqa: PLC0415 414 import time as _time # noqa: PLC0415 415 ref = now if isinstance(now, datetime.datetime) else datetime.datetime.now() 416 ref = ref.replace(hour=hours, minute=minutes, second=seconds, microsecond=0) 417 utc_unix = ref.timestamp() 418 # `time.timezone` is seconds west of UTC (positive); we want the 419 # opposite sign. `altzone` applies when DST is in effect. 420 if _time.localtime(utc_unix).tm_isdst > 0 and _time.daylight: 421 offset = -_time.altzone 422 else: 423 offset = -_time.timezone 424 return int(utc_unix + offset)
Convert an HH:MM:SS fast-clock time to the local-unix value
JMRI's PFT payload expects.
We fold today's date onto the given HH:MM:SS to build a full
datetime, then bias UTC unix seconds by the current timezone offset
(matching JMRI's (fastClockTime + timeZoneOffset) / 1000L).