lr-shuttle 0.2.2__py3-none-any.whl → 0.2.12__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.
shuttle/constants.py CHANGED
@@ -6,7 +6,6 @@ from __future__ import annotations
6
6
 
7
7
  from typing import Dict, Set
8
8
 
9
-
10
9
  SPI_ALLOWED_FIELDS: Set[str] = {
11
10
  "cs_active",
12
11
  "setup_us",
Binary file
shuttle/prodtest.py CHANGED
@@ -62,9 +62,7 @@ def command(
62
62
  ) -> dict:
63
63
  """Build an NDJSON-ready spi.xfer payload for a prodtest command."""
64
64
 
65
- return timo.command_payload(
66
- _build_command_bytes(opcode, arguments), params=params
67
- )
65
+ return timo.command_payload(_build_command_bytes(opcode, arguments), params=params)
68
66
 
69
67
 
70
68
  def reset() -> dict:
shuttle/serial_client.py CHANGED
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  import json
8
8
  import secrets
9
9
  import threading
10
+ import time
10
11
  from datetime import datetime, timezone
11
12
  from pathlib import Path
12
13
  from typing import Any, Callable, Dict, List, Optional
@@ -17,6 +18,11 @@ import serial
17
18
  from serial import SerialException
18
19
  from .constants import DEFAULT_BAUD, DEFAULT_TIMEOUT
19
20
 
21
+ USB_CDC_PACKET_SIZE = 64
22
+ # Delay between USB CDC write chunks to avoid overwhelming the host USB stack with back-to-back packets.
23
+ # Tune for typical desktop OS USB stacks & current use cases; may need adjustment for other hosts.
24
+ USB_CDC_WRITE_DELAY_S = 0.000
25
+
20
26
 
21
27
  class ShuttleSerialError(Exception):
22
28
  """Raised when serial transport encounters an unrecoverable error."""
@@ -228,10 +234,25 @@ class NDJSONSerialClient:
228
234
  seq_tracker: Optional[SequenceTracker] = None,
229
235
  ):
230
236
  try:
231
- self._serial = serial.Serial(port=port, baudrate=baudrate, timeout=timeout)
237
+ self._serial = serial.serial_for_url(
238
+ url=port,
239
+ baudrate=baudrate,
240
+ timeout=timeout,
241
+ do_not_open=True,
242
+ )
243
+ except SerialException as exc: # pragma: no cover - hardware specific
244
+ raise ShuttleSerialError(f"Unable to initialize {port}: {exc}") from exc
245
+
246
+ try:
247
+ if getattr(self._serial, "open", None) is not None:
248
+ if not getattr(self._serial, "is_open", False):
249
+ self._serial.open()
232
250
  except SerialException as exc: # pragma: no cover - hardware specific
233
251
  raise ShuttleSerialError(f"Unable to open {port}: {exc}") from exc
234
- self._serial.reset_input_buffer()
252
+ except AttributeError:
253
+ # Test stubs without an open() method are already "connected"
254
+ pass
255
+ self._reset_input_buffer()
235
256
  self._lock = threading.Lock()
236
257
  self._pending: Dict[int, CommandFuture] = {}
237
258
  self._response_backlog: Dict[int, Dict[str, Any]] = {}
@@ -241,6 +262,7 @@ class NDJSONSerialClient:
241
262
  self._logger = logger
242
263
  self._seq_tracker = seq_tracker
243
264
  self._reader: Optional[threading.Thread] = None
265
+ self._event_callback: Optional[Callable[[Dict[str, Any]], None]] = None
244
266
 
245
267
  def __enter__(self) -> "NDJSONSerialClient":
246
268
  return self
@@ -258,9 +280,41 @@ class NDJSONSerialClient:
258
280
  if getattr(self, "_serial", None) and self._serial.is_open:
259
281
  self._serial.close()
260
282
 
283
+ def _reset_input_buffer(self) -> None:
284
+ serial_obj = getattr(self, "_serial", None)
285
+ if serial_obj is None:
286
+ return
287
+ reset = getattr(serial_obj, "reset_input_buffer", None)
288
+ if reset is None:
289
+ return
290
+ try:
291
+ reset()
292
+ except SerialException:
293
+ pass
294
+
295
+ def flush_input_and_log(self):
296
+ """Read and log all available data from the serial buffer before sending a command."""
297
+ if not hasattr(self, "_serial"):
298
+ return
299
+ try:
300
+ while True:
301
+ waiting = getattr(self._serial, "in_waiting", 0)
302
+ if not waiting:
303
+ break
304
+ data = self._serial.read(waiting)
305
+ if data:
306
+ self._log_serial("RX", data)
307
+ except Exception:
308
+ pass
309
+ finally:
310
+ self._reset_input_buffer()
311
+
261
312
  def send_command(self, op: str, params: Dict[str, Any]) -> CommandFuture:
262
313
  """Send a command without blocking, returning a future for the response."""
263
314
 
315
+ # Flush and log any unread data before sending a command
316
+ self.flush_input_and_log()
317
+
264
318
  cmd_id = self._next_cmd_id()
265
319
  message: Dict[str, Any] = {"type": "cmd", "id": cmd_id, "op": op}
266
320
  message.update(params)
@@ -304,6 +358,13 @@ class NDJSONSerialClient:
304
358
  self._ensure_reader_started()
305
359
  return listener
306
360
 
361
+ def set_event_callback(
362
+ self, callback: Optional[Callable[[Dict[str, Any]], None]]
363
+ ) -> None:
364
+ """Register a callback for every device event, regardless of listeners."""
365
+
366
+ self._event_callback = callback
367
+
307
368
  def spi_xfer(
308
369
  self, *, tx: str, n: Optional[int] = None, **overrides: Any
309
370
  ) -> Dict[str, Any]:
@@ -345,6 +406,12 @@ class NDJSONSerialClient:
345
406
  payload["uart"] = uart
346
407
  return self._command("uart.cfg", payload)
347
408
 
409
+ def wifi_cfg(self, wifi: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
410
+ payload: Dict[str, Any] = {}
411
+ if wifi:
412
+ payload["wifi"] = wifi
413
+ return self._command("wifi.cfg", payload)
414
+
348
415
  def uart_tx(self, data: str, port: Optional[int] = None) -> Dict[str, Any]:
349
416
  payload: Dict[str, Any] = {"data": data}
350
417
  if port is not None:
@@ -405,33 +472,53 @@ class NDJSONSerialClient:
405
472
  def _write(self, message: Dict[str, Any]) -> None:
406
473
  serialized = json.dumps(message, separators=(",", ":"))
407
474
  payload = serialized.encode("utf-8") + b"\n"
475
+ total_written = 0
408
476
  with self._lock:
409
- self._serial.write(payload)
477
+ while total_written < len(payload):
478
+ # Throttling writes to avoid overwhelming the USB stack
479
+ chunk = payload[total_written : total_written + USB_CDC_PACKET_SIZE]
480
+ written = self._serial.write(chunk)
481
+ if written != len(chunk):
482
+ raise ShuttleSerialError(
483
+ f"Short write to serial port: wrote {written} of {len(chunk)} bytes"
484
+ )
485
+ total_written += written
486
+ if total_written < len(payload):
487
+ time.sleep(USB_CDC_WRITE_DELAY_S)
410
488
  self._log_serial("TX", payload)
411
489
 
412
490
  def _read(self) -> Optional[Dict[str, Any]]:
413
- try:
414
- line = self._serial.readline()
415
- except SerialException as exc: # pragma: no cover - hardware specific
416
- raise ShuttleSerialError(f"Serial read failed: {exc}") from exc
417
- if not line:
418
- return None
419
- self._log_serial("RX", line)
420
- stripped = line.strip()
421
- if not stripped:
422
- return None
423
- try:
424
- decoded = stripped.decode("utf-8")
425
- except UnicodeDecodeError as exc:
426
- raise ShuttleSerialError(f"Invalid UTF-8 from device: {exc}") from exc
427
- try:
428
- message = json.loads(decoded)
429
- except json.JSONDecodeError as exc:
430
- raise ShuttleSerialError(
431
- f"Invalid JSON from device: {decoded} ({exc})"
432
- ) from exc
433
- self._record_sequence(message)
434
- return message
491
+ while True:
492
+ try:
493
+ line = self._serial.readline()
494
+ except SerialException as exc: # pragma: no cover - hardware specific
495
+ raise ShuttleSerialError(f"Serial read failed: {exc}") from exc
496
+ if not line:
497
+ return None
498
+ self._log_serial("RX", line)
499
+ stripped = line.strip()
500
+ if not stripped:
501
+ return None
502
+ try:
503
+ decoded = stripped.decode("utf-8")
504
+ except UnicodeDecodeError as exc:
505
+ self._reset_input_buffer()
506
+ raise ShuttleSerialError(f"Invalid UTF-8 from device: {exc}") from exc
507
+ trimmed = decoded.lstrip()
508
+ if not trimmed:
509
+ continue
510
+ if trimmed[0] not in ("{", "["):
511
+ self._reset_input_buffer()
512
+ continue
513
+ try:
514
+ message = json.loads(decoded)
515
+ except json.JSONDecodeError as exc:
516
+ self._reset_input_buffer()
517
+ raise ShuttleSerialError(
518
+ f"Invalid JSON from device: {decoded} ({exc})"
519
+ ) from exc
520
+ self._record_sequence(message)
521
+ return message
435
522
 
436
523
  def _dispatch(self, message: Dict[str, Any]) -> None:
437
524
  mtype = message.get("type")
@@ -450,6 +537,7 @@ class NDJSONSerialClient:
450
537
  ev_name = message.get("ev")
451
538
  if not isinstance(ev_name, str):
452
539
  raise ShuttleSerialError("Device event missing ev field")
540
+ self._emit_event_callback(message)
453
541
  with self._lock:
454
542
  listeners = list(self._event_listeners.get(ev_name, []))
455
543
  for listener in listeners:
@@ -491,3 +579,13 @@ class NDJSONSerialClient:
491
579
  future.mark_exception(exc)
492
580
  for listener in listeners:
493
581
  listener.fail(exc)
582
+
583
+ def _emit_event_callback(self, message: Dict[str, Any]) -> None:
584
+ callback = getattr(self, "_event_callback", None)
585
+ if callback is None:
586
+ return
587
+ try:
588
+ callback(message)
589
+ except Exception:
590
+ # Callback failures should not kill the serial reader loop
591
+ pass
shuttle/timo.py CHANGED
@@ -1,8 +1,9 @@
1
1
  #! /usr/bin/env python
2
2
  # -*- coding: utf-8 -*-
3
3
  """Helpers for TiMo SPI command sequences."""
4
+
4
5
  from __future__ import annotations
5
- from typing import Any, Dict, Sequence
6
+ from typing import Any, BinaryIO, Dict, Iterator, Sequence, Tuple, Union
6
7
 
7
8
  NOP_OPCODE = 0xFF
8
9
  READ_REG_BASE = 0b00000000
@@ -18,8 +19,21 @@ READ_RDM_CMD = 0x83 # 1000 0011: Read received RDM request
18
19
  WRITE_DMX_CMD = 0x91 # 1001 0001: Write DMX generation buffer
19
20
  WRITE_RDM_CMD = 0x92 # 1001 0010: Write an RDM response
20
21
 
22
+ FW_BLOCK_CMD_1 = 0x8E
23
+ FW_BLOCK_CMD_2 = 0x8F
24
+ FW_BLOCK_CMD_1_SIZE = 254
25
+ FW_BLOCK_CMD_2_SIZE = 18
26
+ CCI_CHUNK_SIZE = FW_BLOCK_CMD_1_SIZE + FW_BLOCK_CMD_2_SIZE
27
+ CCI_HEADER_SIZE = 4
28
+
21
29
  IRQ_FLAG_RESTART = 0x80 # Bit 7 signals the slave could not process the transfer
22
- IRQ_WAIT_TIMEOUT_US = 1_000 # 1 millisecond
30
+ IRQ_WAIT_TIMEOUT_US = 1_000_000 # Allow up to 1 second for IRQ trailing edge
31
+
32
+ WaitIrqOption = Union[Dict[str, Any], bool, None]
33
+ DEFAULT_WAIT_IRQ: Dict[str, Any] = {
34
+ "edge": "trailing",
35
+ "timeout_us": IRQ_WAIT_TIMEOUT_US,
36
+ }
23
37
 
24
38
  # Selected register map and field descriptions from TiMo SPI interface docs
25
39
  REGISTER_MAP: Dict[str, Dict[str, Any]] = {
@@ -39,6 +53,12 @@ REGISTER_MAP: Dict[str, Dict[str, Any]] = {
39
53
  "access": "R/W",
40
54
  "desc": "0=UART RDM, 1=SPI RDM",
41
55
  },
56
+ "UPDATE_MODE": {
57
+ "bits": (5, 5),
58
+ "access": "W",
59
+ "reset": 0,
60
+ "desc": "1=driver update mode",
61
+ },
42
62
  "RADIO_ENABLE": {
43
63
  "bits": (7, 7),
44
64
  "access": "R/W",
@@ -229,9 +249,9 @@ REGISTER_MAP: Dict[str, Dict[str, Any]] = {
229
249
  },
230
250
  "DEVICE_NAME": {
231
251
  "address": 0x36,
232
- "length": 16,
252
+ "length": 32,
233
253
  "fields": {
234
- "DEVICE_NAME": {"bits": (0, 128), "access": "R/W"},
254
+ "DEVICE_NAME": {"bits": (0, 255), "access": "R/W"},
235
255
  },
236
256
  },
237
257
  "UNIVERSE_NAME": {
@@ -420,7 +440,12 @@ def nop_sequence() -> Sequence[Dict[str, Any]]:
420
440
  return [command_payload(nop_frame())]
421
441
 
422
442
 
423
- def read_reg_sequence(address: int, length: int) -> Sequence[Dict[str, Any]]:
443
+ def read_reg_sequence(
444
+ address: int,
445
+ length: int,
446
+ *,
447
+ wait_irq: WaitIrqOption = None,
448
+ ) -> Sequence[Dict[str, Any]]:
424
449
  """Build the SPI transfer sequence to read a TiMo register."""
425
450
 
426
451
  if not 0 <= address <= READ_REG_ADDR_MASK:
@@ -429,10 +454,19 @@ def read_reg_sequence(address: int, length: int) -> Sequence[Dict[str, Any]]:
429
454
  raise ValueError(f"length must be 1..{READ_REG_MAX_LEN}")
430
455
 
431
456
  command_byte = READ_REG_BASE | (address & READ_REG_ADDR_MASK)
432
- # Wait for IRQ trailing edge (high-to-low) after command phase
457
+ # Allow callers to override the default IRQ wait behavior for edge cases
458
+ if wait_irq is False:
459
+ resolved_wait = None
460
+ elif wait_irq is None or wait_irq is True:
461
+ resolved_wait = dict(DEFAULT_WAIT_IRQ)
462
+ else:
463
+ resolved_wait = wait_irq
464
+ command_params: Dict[str, Any] = {}
465
+ if resolved_wait is not None:
466
+ command_params["wait_irq"] = resolved_wait
433
467
  command_transfer = command_payload(
434
468
  bytes([command_byte]),
435
- params={"wait_irq": {"edge": "trailing", "timeout_us": IRQ_WAIT_TIMEOUT_US}},
469
+ params=command_params or None,
436
470
  )
437
471
  payload_transfer = command_payload(bytes([READ_REG_DUMMY] + [0x00] * length))
438
472
  return [command_transfer, payload_transfer]
@@ -497,3 +531,29 @@ def requires_restart(irq_flags: int) -> bool:
497
531
  """Return True when bit 7 indicates the command must be retried."""
498
532
 
499
533
  return bool(irq_flags & IRQ_FLAG_RESTART)
534
+
535
+
536
+ def read_cci_header(stream: BinaryIO) -> bytes:
537
+ """Read and return the 4-byte TiMo CCI header."""
538
+
539
+ header = stream.read(CCI_HEADER_SIZE)
540
+ if len(header) != CCI_HEADER_SIZE:
541
+ raise ValueError("CCI firmware header must contain 4 bytes")
542
+ return header
543
+
544
+
545
+ def iter_cci_chunks(stream: BinaryIO) -> Iterator[Tuple[int, bytes, bytes]]:
546
+ """Yield successive FW block payloads (254+18 bytes) from a TiMo CCI image."""
547
+
548
+ block_index = 0
549
+ while True:
550
+ chunk_1 = stream.read(FW_BLOCK_CMD_1_SIZE)
551
+ if not chunk_1:
552
+ break
553
+ if len(chunk_1) != FW_BLOCK_CMD_1_SIZE:
554
+ raise ValueError("CCI firmware truncated in FW_BLOCK_CMD_1 payload")
555
+ chunk_2 = stream.read(FW_BLOCK_CMD_2_SIZE)
556
+ if len(chunk_2) != FW_BLOCK_CMD_2_SIZE:
557
+ raise ValueError("CCI firmware truncated in FW_BLOCK_CMD_2 payload")
558
+ block_index += 1
559
+ yield block_index, chunk_1, chunk_2
@@ -1,18 +0,0 @@
1
- shuttle/cli.py,sha256=eMJEEmHG6Ar3vQsrQmCbPLY0pHUJg6gUyUDCaW0KNGM,87851
2
- shuttle/constants.py,sha256=GUlAg3iEuPxLQ2mDCvlv5gVXHnlawl_YeLtaUSqsnPM,757
3
- shuttle/flash.py,sha256=9ph23MHL40SjKZoL38Sbd3JbykGb-ECxvzBzCIjAues,4492
4
- shuttle/prodtest.py,sha256=1q-1dZYtrWBpI_e0jPgROVGbb_42Y0q0DIxDoo4MWUk,8020
5
- shuttle/serial_client.py,sha256=CnqWpC4CyxNXzsQQgRQsGwkDf19OoIEYvipu4kt2IQo,17392
6
- shuttle/timo.py,sha256=1K18y0QtDF2lw2Abeok9PgrpPUiCEbQdGQXOQik75Hw,16481
7
- shuttle/firmware/__init__.py,sha256=KRXyz3xJ2GIB473tCHAky3DdPIQb78gX64Qn-uu55To,120
8
- shuttle/firmware/esp32c5/__init__.py,sha256=U2xXnb80Wv8EJaJ6Tv9iev1mVlpoaEeqsNmjmEtxdFQ,41
9
- shuttle/firmware/esp32c5/boot_app0.bin,sha256=-UxdeGp6j6sGrF0Q4zvzdxGmaXY23AN1WeoZzEEKF_A,8192
10
- shuttle/firmware/esp32c5/devboard.ino.bin,sha256=vH-IBzbJCpSKgCLDl6snXqe2d5PFJJ3RDVQq41b3iKg,378880
11
- shuttle/firmware/esp32c5/devboard.ino.bootloader.bin,sha256=LPU51SdUwebYemCZb5Pya-wGe7RC4UXrkRmBnsHePp0,20784
12
- shuttle/firmware/esp32c5/devboard.ino.partitions.bin,sha256=FIuVnL_xw4qo4dXAup1hLFSZe5ReVqY_QSI-72UGU6E,3072
13
- shuttle/firmware/esp32c5/manifest.json,sha256=CPOegfEK4PTtI6UPeohuUKkJNeg0t8aWntEczpoxYt4,480
14
- lr_shuttle-0.2.2.dist-info/METADATA,sha256=eS1NFB_YNkvsq7eT4akMzyUij0ormtqHHtagAMghonA,13611
15
- lr_shuttle-0.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
- lr_shuttle-0.2.2.dist-info/entry_points.txt,sha256=obqdFPgvQLB1_EWcnD9ch8HjQRlNVT_pdB_EidDRDco,44
17
- lr_shuttle-0.2.2.dist-info/top_level.txt,sha256=PtNxNQQdya-Xs8DYublNTBTa8c1TrtfEpQ0lUd_OeZY,8
18
- lr_shuttle-0.2.2.dist-info/RECORD,,