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.
- lr_shuttle-0.1.0.dist-info/METADATA +244 -0
- lr_shuttle-0.1.0.dist-info/RECORD +10 -0
- lr_shuttle-0.1.0.dist-info/WHEEL +5 -0
- lr_shuttle-0.1.0.dist-info/entry_points.txt +2 -0
- lr_shuttle-0.1.0.dist-info/top_level.txt +1 -0
- shuttle/cli.py +1820 -0
- shuttle/constants.py +41 -0
- shuttle/prodtest.py +120 -0
- shuttle/serial_client.py +478 -0
- shuttle/timo.py +499 -0
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)
|