lr-shuttle 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lr-shuttle might be problematic. Click here for more details.

shuttle/timo.py ADDED
@@ -0,0 +1,499 @@
1
+ #! /usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """Helpers for TiMo SPI command sequences."""
4
+ from __future__ import annotations
5
+ from typing import Any, Dict, Sequence
6
+
7
+ NOP_OPCODE = 0xFF
8
+ READ_REG_BASE = 0b00000000
9
+ READ_REG_ADDR_MASK = 0x3F
10
+ READ_REG_MAX_LEN = 32
11
+ READ_REG_DUMMY = 0xFF
12
+ WRITE_REG_BASE = 0b01000000 # 0x40, 01AAAAAA
13
+ WRITE_REG_ADDR_MASK = 0x3F
14
+ DMX_READ_CMD = 0x81 # 1000 0001: Read latest received DMX values
15
+ DMX_READ_MAX_LEN = 512 # Arbitrary, adjust as needed for DMX universe
16
+ READ_ASC_CMD = 0x82 # 1000 0010: Read latest ASC frame
17
+ READ_RDM_CMD = 0x83 # 1000 0011: Read received RDM request
18
+ WRITE_DMX_CMD = 0x91 # 1001 0001: Write DMX generation buffer
19
+ WRITE_RDM_CMD = 0x92 # 1001 0010: Write an RDM response
20
+
21
+ IRQ_FLAG_RESTART = 0x80 # Bit 7 signals the slave could not process the transfer
22
+ IRQ_WAIT_TIMEOUT_US = 1_000 # 1 millisecond
23
+
24
+ # Selected register map and field descriptions from TiMo SPI interface docs
25
+ REGISTER_MAP: Dict[str, Dict[str, Any]] = {
26
+ "CONFIG": {
27
+ "address": 0x00,
28
+ "length": 1,
29
+ "fields": {
30
+ "UART_EN": {
31
+ "bits": (0, 0),
32
+ "access": "R/W",
33
+ "reset": 1,
34
+ "desc": "Enable UART DMX output",
35
+ },
36
+ "RADIO_TX_RX_MODE": {"bits": (1, 1), "access": "R/W", "desc": "0=RX, 1=TX"},
37
+ "SPI_RDM": {
38
+ "bits": (3, 3),
39
+ "access": "R/W",
40
+ "desc": "0=UART RDM, 1=SPI RDM",
41
+ },
42
+ "RADIO_ENABLE": {
43
+ "bits": (7, 7),
44
+ "access": "R/W",
45
+ "reset": 1,
46
+ "desc": "Enable wireless",
47
+ },
48
+ },
49
+ },
50
+ "STATUS": {
51
+ "address": 0x01,
52
+ "length": 1,
53
+ "fields": {
54
+ "LINKED": {
55
+ "bits": (0, 0),
56
+ "access": "R/W",
57
+ "desc": "1=linked (write 1 to unlink)",
58
+ },
59
+ "RF_LINK": {
60
+ "bits": (1, 1),
61
+ "access": "R/W",
62
+ "reset": 0,
63
+ "desc": "Radio link active",
64
+ },
65
+ "IDENTIFY": {
66
+ "bits": (2, 2),
67
+ "access": "R/W",
68
+ "reset": 0,
69
+ "desc": "RDM identify active",
70
+ },
71
+ "DMX": {"bits": (3, 3), "access": "R", "reset": 0, "desc": "DMX available"},
72
+ "UPDATE_MODE": {
73
+ "bits": (7, 7),
74
+ "access": "R",
75
+ "reset": 0,
76
+ "desc": "1=driver update mode",
77
+ },
78
+ },
79
+ },
80
+ "IRQ_MASK": {
81
+ "address": 0x02,
82
+ "length": 1,
83
+ "fields": {
84
+ "RX_DMX_IRQ_EN": {"bits": (0, 0), "access": "R/W", "reset": 0},
85
+ "LOST_DMX_IRQ_EN": {"bits": (1, 1), "access": "R/W", "reset": 0},
86
+ "DMX_CHANGED_IRQ_EN": {"bits": (2, 2), "access": "R/W", "reset": 0},
87
+ "RF_LINK_IRQ_EN": {"bits": (3, 3), "access": "R/W", "reset": 0},
88
+ "ASC_IRQ_EN": {"bits": (4, 4), "access": "R/W", "reset": 0},
89
+ "IDENTIFY_IRQ_EN": {"bits": (5, 5), "access": "R/W", "reset": 0},
90
+ "EXTENDED_IRQ_EN": {"bits": (6, 6), "access": "R/W", "reset": 0},
91
+ },
92
+ },
93
+ "IRQ_FLAGS": {
94
+ "address": 0x03,
95
+ "length": 1,
96
+ "fields": {
97
+ "RX_DMX_IRQ": {"bits": (0, 0), "access": "R", "reset": 0},
98
+ "LOST_DMX_IRQ": {"bits": (1, 1), "access": "R", "reset": 0},
99
+ "DMX_CHANGED_IRQ": {"bits": (2, 2), "access": "R", "reset": 0},
100
+ "RF_LINK_IRQ": {"bits": (3, 3), "access": "R", "reset": 0},
101
+ "ASC_IRQ": {"bits": (4, 4), "access": "R", "reset": 0},
102
+ "IDENTIFY_IRQ": {"bits": (5, 5), "access": "R", "reset": 0},
103
+ "EXTENDED_IRQ": {"bits": (6, 6), "access": "R", "reset": 0},
104
+ "SPI_DEVICE_BUSY": {"bits": (7, 7), "access": "R", "reset": 0},
105
+ },
106
+ },
107
+ "DMX_WINDOW": {
108
+ "address": 0x04,
109
+ "length": 4,
110
+ "fields": {
111
+ "WINDOW_SIZE": {"bits": (0, 15), "access": "R/W", "reset": 512},
112
+ "START_ADDRESS": {"bits": (16, 31), "access": "R/W", "reset": 0},
113
+ },
114
+ },
115
+ "ASC_FRAME": {
116
+ "address": 0x05,
117
+ "length": 3,
118
+ "fields": {
119
+ "START_CODE": {"bits": (0, 7), "access": "R", "reset": 0},
120
+ "ASC_FRAME_LENGTH": {"bits": (8, 23), "access": "R", "reset": 0},
121
+ },
122
+ },
123
+ "LINK_QUALITY": {
124
+ "address": 0x06,
125
+ "length": 1,
126
+ "fields": {
127
+ "PDR": {"bits": (0, 7), "access": "R", "desc": "0=0%, 255=100%"},
128
+ },
129
+ },
130
+ "ANTENNA": {
131
+ "address": 0x07,
132
+ "length": 1,
133
+ "fields": {
134
+ "ANT_SEL": {
135
+ "bits": (0, 0),
136
+ "access": "R/W",
137
+ "reset": 0,
138
+ "desc": "0=on-board,1=IPEX",
139
+ },
140
+ },
141
+ },
142
+ "DMX_SPEC": {
143
+ "address": 0x08,
144
+ "length": 8,
145
+ "fields": {
146
+ "N_CHANNELS": {"bits": (0, 15), "access": "R/W", "reset": 512},
147
+ "INTERSLOT_TIME": {"bits": (16, 31), "access": "R/W", "reset": 0},
148
+ "REFRESH_PERIOD": {"bits": (32, 63), "access": "R/W", "reset": 25000},
149
+ },
150
+ },
151
+ "DMX_CONTROL": {
152
+ "address": 0x09,
153
+ "length": 1,
154
+ "fields": {
155
+ "ENABLE": {"bits": (0, 0), "access": "R/W", "reset": 0},
156
+ },
157
+ },
158
+ "EXTENDED_IRQ_MASK": {
159
+ "address": 0x0A,
160
+ "length": 4,
161
+ "fields": {
162
+ "RDM_REQUEST_EN": {"bits": (0, 0), "access": "R"},
163
+ "UNIV_META_CHANGED_EN": {"bits": (6, 6), "access": "R/W", "reset": 0},
164
+ },
165
+ },
166
+ "EXTENDED_IRQ_FLAGS": {
167
+ "address": 0x0B,
168
+ "length": 4,
169
+ "fields": {
170
+ "RDM_REQUEST": {"bits": (0, 0), "access": "R"},
171
+ "UNIV_META_CHANGED": {"bits": (6, 6), "access": "R/W", "reset": 0},
172
+ },
173
+ },
174
+ "RF_PROTOCOL": {
175
+ "address": 0x0C,
176
+ "length": 1,
177
+ "fields": {
178
+ "TX_PROTOCOL": {
179
+ "bits": (0, 7),
180
+ "access": "R/W",
181
+ "desc": "0=CRMX,1=G3,3=G4S",
182
+ },
183
+ },
184
+ },
185
+ "VERSION": {
186
+ "address": 0x10,
187
+ "length": 8,
188
+ "fields": {
189
+ "FW_VERSION": {"bits": (0, 31), "access": "R"},
190
+ "HW_VERSION": {"bits": (32, 63), "access": "R"},
191
+ },
192
+ },
193
+ "RF_POWER": {
194
+ "address": 0x11,
195
+ "length": 1,
196
+ "fields": {
197
+ "OUTPUT_POWER": {"bits": (0, 7), "access": "R/W", "reset": 3},
198
+ },
199
+ },
200
+ "BLOCKED_CHANNELS": {
201
+ "address": 0x12,
202
+ "length": 11,
203
+ "fields": {
204
+ "FLAGS": {"bits": (0, 87), "access": "R/W", "reset": 0},
205
+ },
206
+ },
207
+ "BINDING_UID": {
208
+ "address": 0x20,
209
+ "length": 6,
210
+ "fields": {
211
+ "UID": {"bits": (0, 47), "access": "R/W", "reset": 0},
212
+ },
213
+ },
214
+ "LINKING_KEY": {
215
+ "address": 0x21,
216
+ "length": 10,
217
+ "fields": {
218
+ "CODE": {"bits": (0, 63), "access": "W"},
219
+ "MODE": {"bits": (64, 71), "access": "W"},
220
+ "UNIVERSE": {"bits": (72, 79), "access": "W"},
221
+ },
222
+ },
223
+ "UNIVERSE_COLOR": {
224
+ "address": 0x33,
225
+ "length": 3,
226
+ "fields": {
227
+ "RGB_VALUE": {"bits": (0, 23), "access": "R/W"},
228
+ },
229
+ },
230
+ "DEVICE_NAME": {
231
+ "address": 0x36,
232
+ "length": 16,
233
+ "fields": {
234
+ "DEVICE_NAME": {"bits": (0, 128), "access": "R/W"},
235
+ },
236
+ },
237
+ "UNIVERSE_NAME": {
238
+ "address": 0x37,
239
+ "length": 16,
240
+ "fields": {
241
+ "UNIVERSE_NAME": {"bits": (0, 128), "access": "R/W"},
242
+ },
243
+ },
244
+ "PRODUCT_ID": {
245
+ "address": 0x3F,
246
+ "length": 4,
247
+ "fields": {
248
+ "PRODUCT_ID": {
249
+ "bits": (0, 32),
250
+ "access": "R",
251
+ "desc": "TiMo product ID 0xF1,0x32,0x00,0x00",
252
+ },
253
+ },
254
+ },
255
+ }
256
+
257
+
258
+ def slice_bits(data: bytes, lo: int, hi: int) -> int:
259
+ # inclusive bit range, big-endian bytes
260
+ bit_len = hi - lo + 1
261
+ full = int.from_bytes(data, "big")
262
+ shift = len(data) * 8 - hi - 1
263
+ return (full >> shift) & ((1 << bit_len) - 1)
264
+
265
+
266
+ def command_payload(
267
+ tx: bytes, *, n: int | None = None, params: Dict[str, Any] | None = None
268
+ ) -> Dict[str, Any]:
269
+ """Build an NDJSON-friendly spi.xfer payload from raw bytes."""
270
+
271
+ payload: Dict[str, Any] = {"tx": tx.hex(), "n": n if n is not None else len(tx)}
272
+ if params:
273
+ payload.update(params)
274
+ return payload
275
+
276
+
277
+ class ReadDMXResult:
278
+ def __init__(
279
+ self, length: int, data: bytes, irq_flags_command: int, irq_flags_payload: int
280
+ ):
281
+ self.length = length
282
+ self.data = data
283
+ self.irq_flags_command = irq_flags_command
284
+ self.irq_flags_payload = irq_flags_payload
285
+
286
+
287
+ def read_dmx_sequence(length: int) -> Sequence[Dict[str, Any]]:
288
+ """Build the SPI transfer sequence to read DMX values from TiMo."""
289
+ if not 1 <= length <= DMX_READ_MAX_LEN:
290
+ raise ValueError(f"length must be 1..{DMX_READ_MAX_LEN}")
291
+ command_transfer = command_payload(
292
+ bytes([DMX_READ_CMD]),
293
+ params={"wait_irq": {"edge": "trailing", "timeout_us": IRQ_WAIT_TIMEOUT_US}},
294
+ )
295
+ payload_transfer = command_payload(bytes([READ_REG_DUMMY] + [0x00] * (length)))
296
+ return [command_transfer, payload_transfer]
297
+
298
+
299
+ def parse_read_dmx_response(length: int, rx_frames: Sequence[str]) -> ReadDMXResult:
300
+ """Parse the RX frames returned from a read-dmx sequence."""
301
+ if len(rx_frames) != 2:
302
+ raise ValueError("read-dmx expects exactly two RX frames")
303
+ cmd_frame = bytes.fromhex(rx_frames[0]) if rx_frames[0] else b""
304
+ payload_frame = bytes.fromhex(rx_frames[1]) if rx_frames[1] else b""
305
+ if len(cmd_frame) != 1:
306
+ raise ValueError("Command frame must contain exactly one byte")
307
+ if len(payload_frame) < length + 1:
308
+ raise ValueError("Payload frame shorter than expected")
309
+ irq_cmd = cmd_frame[0]
310
+ irq_payload = payload_frame[0]
311
+ data = payload_frame[1 : 1 + length]
312
+ return ReadDMXResult(
313
+ length=length,
314
+ data=data,
315
+ irq_flags_command=irq_cmd,
316
+ irq_flags_payload=irq_payload,
317
+ )
318
+
319
+
320
+ class WriteRegisterResult:
321
+ def __init__(
322
+ self, address: int, data: bytes, irq_flags_command: int, irq_flags_payload: int
323
+ ):
324
+ self.address = address
325
+ self.data = data
326
+ self.irq_flags_command = irq_flags_command
327
+ self.irq_flags_payload = irq_flags_payload
328
+
329
+
330
+ def write_reg_sequence(address: int, data: bytes) -> Sequence[Dict[str, Any]]:
331
+ """Build the SPI transfer sequence to write a TiMo register."""
332
+ if not 0 <= address <= WRITE_REG_ADDR_MASK:
333
+ raise ValueError("Register address must be in range 0-63")
334
+ if not 1 <= len(data) <= READ_REG_MAX_LEN:
335
+ raise ValueError(f"data length must be 1..{READ_REG_MAX_LEN}")
336
+ command_byte = WRITE_REG_BASE | (address & WRITE_REG_ADDR_MASK)
337
+ command_transfer = command_payload(
338
+ bytes([command_byte]),
339
+ params={"wait_irq": {"edge": "trailing", "timeout_us": IRQ_WAIT_TIMEOUT_US}},
340
+ )
341
+ payload_transfer = command_payload(bytes([READ_REG_DUMMY]) + data)
342
+ return [command_transfer, payload_transfer]
343
+
344
+
345
+ def parse_write_reg_response(
346
+ address: int, data: bytes, rx_frames: Sequence[str]
347
+ ) -> WriteRegisterResult:
348
+ """Parse the RX frames returned from a write-register sequence."""
349
+ if len(rx_frames) != 2:
350
+ raise ValueError("write-reg expects exactly two RX frames")
351
+ cmd_frame = bytes.fromhex(rx_frames[0]) if rx_frames[0] else b""
352
+ payload_frame = bytes.fromhex(rx_frames[1]) if rx_frames[1] else b""
353
+ if len(cmd_frame) != 1:
354
+ raise ValueError("Command frame must contain exactly one byte")
355
+ if len(payload_frame) < 1:
356
+ raise ValueError("Payload frame shorter than expected")
357
+ irq_cmd = cmd_frame[0]
358
+ irq_payload = payload_frame[0]
359
+ return WriteRegisterResult(
360
+ address=address,
361
+ data=data,
362
+ irq_flags_command=irq_cmd,
363
+ irq_flags_payload=irq_payload,
364
+ )
365
+
366
+
367
+ def read_asc_sequence(length: int) -> Sequence[Dict[str, Any]]:
368
+ """Build the SPI transfer sequence to read an ASC frame."""
369
+
370
+ if not 1 <= length <= DMX_READ_MAX_LEN:
371
+ raise ValueError(f"length must be 1..{DMX_READ_MAX_LEN}")
372
+ command_transfer = command_payload(
373
+ bytes([READ_ASC_CMD]),
374
+ params={"wait_irq": {"edge": "trailing", "timeout_us": IRQ_WAIT_TIMEOUT_US}},
375
+ )
376
+ payload_transfer = command_payload(bytes([READ_REG_DUMMY] + [0x00] * length))
377
+ return [command_transfer, payload_transfer]
378
+
379
+
380
+ def read_rdm_sequence(length: int) -> Sequence[Dict[str, Any]]:
381
+ """Build the SPI transfer sequence to read an RDM request frame."""
382
+
383
+ if not 1 <= length <= DMX_READ_MAX_LEN:
384
+ raise ValueError(f"length must be 1..{DMX_READ_MAX_LEN}")
385
+ command_transfer = command_payload(
386
+ bytes([READ_RDM_CMD]),
387
+ params={"wait_irq": {"edge": "trailing", "timeout_us": IRQ_WAIT_TIMEOUT_US}},
388
+ )
389
+ payload_transfer = command_payload(bytes([READ_REG_DUMMY] + [0x00] * length))
390
+ return [command_transfer, payload_transfer]
391
+
392
+
393
+ def write_dmx_command(payload: bytes) -> Dict[str, Any]:
394
+ """Build a single-frame command to write DMX data."""
395
+
396
+ return command_payload(bytes([WRITE_DMX_CMD]) + payload)
397
+
398
+
399
+ def write_rdm_command(payload: bytes) -> Dict[str, Any]:
400
+ """Build a single-frame command to write an RDM response."""
401
+
402
+ return command_payload(bytes([WRITE_RDM_CMD]) + payload)
403
+
404
+
405
+ def nop_frame() -> bytes:
406
+ """Return the raw bytes that implement the TiMo NOP SPI transfer."""
407
+
408
+ return bytes([NOP_OPCODE])
409
+
410
+
411
+ def nop_frame_hex() -> str:
412
+ """Return the compact hexadecimal string for the TiMo NOP SPI transfer."""
413
+
414
+ return nop_frame().hex()
415
+
416
+
417
+ def nop_sequence() -> Sequence[Dict[str, Any]]:
418
+ """Sequence describing the TiMo NOP command (single transfer)."""
419
+
420
+ return [command_payload(nop_frame())]
421
+
422
+
423
+ def read_reg_sequence(address: int, length: int) -> Sequence[Dict[str, Any]]:
424
+ """Build the SPI transfer sequence to read a TiMo register."""
425
+
426
+ if not 0 <= address <= READ_REG_ADDR_MASK:
427
+ raise ValueError("Register address must be in range 0-63")
428
+ if not 1 <= length <= READ_REG_MAX_LEN:
429
+ raise ValueError(f"length must be 1..{READ_REG_MAX_LEN}")
430
+
431
+ command_byte = READ_REG_BASE | (address & READ_REG_ADDR_MASK)
432
+ # Wait for IRQ trailing edge (high-to-low) after command phase
433
+ command_transfer = command_payload(
434
+ bytes([command_byte]),
435
+ params={"wait_irq": {"edge": "trailing", "timeout_us": IRQ_WAIT_TIMEOUT_US}},
436
+ )
437
+ payload_transfer = command_payload(bytes([READ_REG_DUMMY] + [0x00] * length))
438
+ return [command_transfer, payload_transfer]
439
+
440
+
441
+ class ReadRegisterResult:
442
+ def __init__(
443
+ self,
444
+ address: int,
445
+ length: int,
446
+ data: bytes,
447
+ irq_flags_command: int,
448
+ irq_flags_payload: int,
449
+ ):
450
+ self.address = address
451
+ self.length = length
452
+ self.data = data
453
+ self.irq_flags_command = irq_flags_command
454
+ self.irq_flags_payload = irq_flags_payload
455
+
456
+
457
+ def parse_read_reg_response(
458
+ address: int,
459
+ length: int,
460
+ rx_frames: Sequence[str],
461
+ ) -> ReadRegisterResult:
462
+ """Parse the RX frames returned from a read-register sequence."""
463
+
464
+ if len(rx_frames) != 2:
465
+ raise ValueError("read-reg expects exactly two RX frames")
466
+
467
+ cmd_frame = bytes.fromhex(rx_frames[0]) if rx_frames[0] else b""
468
+ payload_frame = bytes.fromhex(rx_frames[1]) if rx_frames[1] else b""
469
+
470
+ if len(cmd_frame) != 1:
471
+ raise ValueError("Command frame must contain exactly one byte")
472
+ if len(payload_frame) < length + 1:
473
+ raise ValueError("Payload frame shorter than expected")
474
+
475
+ irq_cmd = cmd_frame[0]
476
+ irq_payload = payload_frame[0]
477
+ data = payload_frame[1 : 1 + length]
478
+ return ReadRegisterResult(
479
+ address=address,
480
+ length=length,
481
+ data=data,
482
+ irq_flags_command=irq_cmd,
483
+ irq_flags_payload=irq_payload,
484
+ )
485
+
486
+
487
+ def format_bytes(data: bytes) -> str:
488
+ """Pretty hex representation grouped in bytes."""
489
+
490
+ if not data:
491
+ return "—"
492
+ hex_pairs = [data[i : i + 1].hex() for i in range(len(data))]
493
+ return " ".join(hex_pairs)
494
+
495
+
496
+ def requires_restart(irq_flags: int) -> bool:
497
+ """Return True when bit 7 indicates the command must be retried."""
498
+
499
+ return bool(irq_flags & IRQ_FLAG_RESTART)