lr-shuttle 0.2.8__tar.gz → 0.2.10__tar.gz
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.2.8 → lr_shuttle-0.2.10}/PKG-INFO +1 -1
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/pyproject.toml +1 -1
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/PKG-INFO +1 -1
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/SOURCES.txt +1 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/cli.py +42 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/serial_client.py +64 -23
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_cli.py +3 -1
- lr_shuttle-0.2.10/tests/test_flash.py +269 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_serial_client.py +33 -0
- lr_shuttle-0.2.10/tests/test_serial_client_flush.py +70 -0
- lr_shuttle-0.2.8/tests/test_flash.py +0 -92
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/README.md +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/setup.cfg +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/dependency_links.txt +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/entry_points.txt +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/requires.txt +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/top_level.txt +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/constants.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/__init__.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/__init__.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/boot_app0.bin +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/devboard.ino.bootloader.bin +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/devboard.ino.partitions.bin +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/manifest.json +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/flash.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/prodtest.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/timo.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_cli_client.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_cli_edge.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_cli_seq.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_cli_utils.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_prodtest_edge.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_prodtest_helpers.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_timo.py +0 -0
- {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_timo_write.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lr-shuttle"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.10"
|
|
8
8
|
description = "CLI and Python client for host-side of json based serial communication with embedded device bridge."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -151,6 +151,19 @@ def _handle_sys_error_event(event: Dict[str, Any]) -> None:
|
|
|
151
151
|
console.print(f"[red]{' '.join(parts)}[/]")
|
|
152
152
|
|
|
153
153
|
|
|
154
|
+
def _flush_client_input(client) -> None:
|
|
155
|
+
"""Best-effort drain any unread serial noise before issuing commands."""
|
|
156
|
+
|
|
157
|
+
flush = getattr(client, "flush_input_and_log", None)
|
|
158
|
+
if flush is None:
|
|
159
|
+
return
|
|
160
|
+
try:
|
|
161
|
+
flush()
|
|
162
|
+
except Exception:
|
|
163
|
+
# Flushing is opportunistic; failures should not leak into CLI flows.
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
154
167
|
@contextmanager
|
|
155
168
|
def _open_serial_client(
|
|
156
169
|
resolved_port: str,
|
|
@@ -767,6 +780,9 @@ def _execute_timo_sequence(
|
|
|
767
780
|
logger=logger,
|
|
768
781
|
seq_tracker=seq_tracker,
|
|
769
782
|
) as client:
|
|
783
|
+
# Drain any pending serial noise before issuing commands, to avoid
|
|
784
|
+
# mixing stale data into NDJSON responses.
|
|
785
|
+
_flush_client_input(client)
|
|
770
786
|
for transfer in sequence:
|
|
771
787
|
response = client.spi_xfer(**transfer)
|
|
772
788
|
responses.append(response)
|
|
@@ -2634,6 +2650,7 @@ def spi_enable_command(
|
|
|
2634
2650
|
logger=resources.get("logger"),
|
|
2635
2651
|
seq_tracker=resources.get("seq_tracker"),
|
|
2636
2652
|
) as client:
|
|
2653
|
+
_flush_client_input(client)
|
|
2637
2654
|
response = client.spi_enable()
|
|
2638
2655
|
except ShuttleSerialError as exc:
|
|
2639
2656
|
console.print(f"[red]{exc}[/]")
|
|
@@ -2668,6 +2685,7 @@ def spi_disable_command(
|
|
|
2668
2685
|
logger=resources.get("logger"),
|
|
2669
2686
|
seq_tracker=resources.get("seq_tracker"),
|
|
2670
2687
|
) as client:
|
|
2688
|
+
_flush_client_input(client)
|
|
2671
2689
|
response = client.spi_disable()
|
|
2672
2690
|
except ShuttleSerialError as exc:
|
|
2673
2691
|
console.print(f"[red]{exc}[/]")
|
|
@@ -3138,6 +3156,7 @@ def power_command(
|
|
|
3138
3156
|
logger=resources.get("logger"),
|
|
3139
3157
|
seq_tracker=resources.get("seq_tracker"),
|
|
3140
3158
|
) as client:
|
|
3159
|
+
_flush_client_input(client)
|
|
3141
3160
|
method = getattr(client, method_name)
|
|
3142
3161
|
response = method()
|
|
3143
3162
|
except ShuttleSerialError as exc:
|
|
@@ -3171,6 +3190,11 @@ def flash_command(
|
|
|
3171
3190
|
"--erase-first/--no-erase-first",
|
|
3172
3191
|
help="Erase the entire flash before writing",
|
|
3173
3192
|
),
|
|
3193
|
+
sleep_after_flash: float = typer.Option(
|
|
3194
|
+
1.25,
|
|
3195
|
+
"--sleep-after-flash",
|
|
3196
|
+
help="Seconds to wait after flashing to allow device reboot",
|
|
3197
|
+
),
|
|
3174
3198
|
):
|
|
3175
3199
|
"""Flash the bundled firmware image to the devboard."""
|
|
3176
3200
|
|
|
@@ -3194,6 +3218,24 @@ def flash_command(
|
|
|
3194
3218
|
console.print(f"[red]{exc}[/]")
|
|
3195
3219
|
raise typer.Exit(1) from exc
|
|
3196
3220
|
|
|
3221
|
+
if sleep_after_flash:
|
|
3222
|
+
time.sleep(
|
|
3223
|
+
sleep_after_flash
|
|
3224
|
+
) # Give the device a moment to reboot. 0.75s is sometimes too short.
|
|
3225
|
+
|
|
3226
|
+
# After flashing, drain/log any startup output from the device before further commands
|
|
3227
|
+
logger = ctx.obj["logger"] if ctx.obj and "logger" in ctx.obj else None
|
|
3228
|
+
try:
|
|
3229
|
+
from .serial_client import NDJSONSerialClient
|
|
3230
|
+
|
|
3231
|
+
# Use a short timeout just for draining
|
|
3232
|
+
with NDJSONSerialClient(
|
|
3233
|
+
resolved_port, baudrate=baudrate, timeout=0.5, logger=logger
|
|
3234
|
+
) as client:
|
|
3235
|
+
_flush_client_input(client)
|
|
3236
|
+
except Exception:
|
|
3237
|
+
pass
|
|
3238
|
+
|
|
3197
3239
|
label = str(manifest.get("label", board))
|
|
3198
3240
|
console.print(
|
|
3199
3241
|
f"[green]Successfully flashed {label} ({board}) over {resolved_port}[/]"
|
|
Binary file
|
|
@@ -253,7 +253,7 @@ class NDJSONSerialClient:
|
|
|
253
253
|
except AttributeError:
|
|
254
254
|
# Test stubs without an open() method are already "connected"
|
|
255
255
|
pass
|
|
256
|
-
self.
|
|
256
|
+
self._reset_input_buffer()
|
|
257
257
|
self._lock = threading.Lock()
|
|
258
258
|
self._pending: Dict[int, CommandFuture] = {}
|
|
259
259
|
self._response_backlog: Dict[int, Dict[str, Any]] = {}
|
|
@@ -281,9 +281,41 @@ class NDJSONSerialClient:
|
|
|
281
281
|
if getattr(self, "_serial", None) and self._serial.is_open:
|
|
282
282
|
self._serial.close()
|
|
283
283
|
|
|
284
|
+
def _reset_input_buffer(self) -> None:
|
|
285
|
+
serial_obj = getattr(self, "_serial", None)
|
|
286
|
+
if serial_obj is None:
|
|
287
|
+
return
|
|
288
|
+
reset = getattr(serial_obj, "reset_input_buffer", None)
|
|
289
|
+
if reset is None:
|
|
290
|
+
return
|
|
291
|
+
try:
|
|
292
|
+
reset()
|
|
293
|
+
except SerialException:
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
def flush_input_and_log(self):
|
|
297
|
+
"""Read and log all available data from the serial buffer before sending a command."""
|
|
298
|
+
if not hasattr(self, "_serial"):
|
|
299
|
+
return
|
|
300
|
+
try:
|
|
301
|
+
while True:
|
|
302
|
+
waiting = getattr(self._serial, "in_waiting", 0)
|
|
303
|
+
if not waiting:
|
|
304
|
+
break
|
|
305
|
+
data = self._serial.read(waiting)
|
|
306
|
+
if data:
|
|
307
|
+
self._log_serial("RX", data)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
finally:
|
|
311
|
+
self._reset_input_buffer()
|
|
312
|
+
|
|
284
313
|
def send_command(self, op: str, params: Dict[str, Any]) -> CommandFuture:
|
|
285
314
|
"""Send a command without blocking, returning a future for the response."""
|
|
286
315
|
|
|
316
|
+
# Flush and log any unread data before sending a command
|
|
317
|
+
self.flush_input_and_log()
|
|
318
|
+
|
|
287
319
|
cmd_id = self._next_cmd_id()
|
|
288
320
|
message: Dict[str, Any] = {"type": "cmd", "id": cmd_id, "op": op}
|
|
289
321
|
message.update(params)
|
|
@@ -457,28 +489,37 @@ class NDJSONSerialClient:
|
|
|
457
489
|
self._log_serial("TX", payload)
|
|
458
490
|
|
|
459
491
|
def _read(self) -> Optional[Dict[str, Any]]:
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
492
|
+
while True:
|
|
493
|
+
try:
|
|
494
|
+
line = self._serial.readline()
|
|
495
|
+
except SerialException as exc: # pragma: no cover - hardware specific
|
|
496
|
+
raise ShuttleSerialError(f"Serial read failed: {exc}") from exc
|
|
497
|
+
if not line:
|
|
498
|
+
return None
|
|
499
|
+
self._log_serial("RX", line)
|
|
500
|
+
stripped = line.strip()
|
|
501
|
+
if not stripped:
|
|
502
|
+
return None
|
|
503
|
+
try:
|
|
504
|
+
decoded = stripped.decode("utf-8")
|
|
505
|
+
except UnicodeDecodeError as exc:
|
|
506
|
+
self._reset_input_buffer()
|
|
507
|
+
raise ShuttleSerialError(f"Invalid UTF-8 from device: {exc}") from exc
|
|
508
|
+
trimmed = decoded.lstrip()
|
|
509
|
+
if not trimmed:
|
|
510
|
+
continue
|
|
511
|
+
if trimmed[0] not in ("{", "["):
|
|
512
|
+
self._reset_input_buffer()
|
|
513
|
+
continue
|
|
514
|
+
try:
|
|
515
|
+
message = json.loads(decoded)
|
|
516
|
+
except json.JSONDecodeError as exc:
|
|
517
|
+
self._reset_input_buffer()
|
|
518
|
+
raise ShuttleSerialError(
|
|
519
|
+
f"Invalid JSON from device: {decoded} ({exc})"
|
|
520
|
+
) from exc
|
|
521
|
+
self._record_sequence(message)
|
|
522
|
+
return message
|
|
482
523
|
|
|
483
524
|
def _dispatch(self, message: Dict[str, Any]) -> None:
|
|
484
525
|
mtype = message.get("type")
|
|
@@ -3018,6 +3018,8 @@ def test_flash_command_invokes_helper(monkeypatch, recorded_console):
|
|
|
3018
3018
|
"--board",
|
|
3019
3019
|
"esp32c5",
|
|
3020
3020
|
"--erase-first",
|
|
3021
|
+
"--sleep-after-flash",
|
|
3022
|
+
"0",
|
|
3021
3023
|
],
|
|
3022
3024
|
)
|
|
3023
3025
|
|
|
@@ -3695,7 +3697,7 @@ def test_ndjson_serial_client_decoder_errors(monkeypatch, recorded_console):
|
|
|
3695
3697
|
with cli_module.NDJSONSerialClient("/dev/null", baudrate=1, timeout=0.1) as client:
|
|
3696
3698
|
with pytest.raises(cli_module.ShuttleSerialError):
|
|
3697
3699
|
client._read()
|
|
3698
|
-
stub._lines = [b"
|
|
3700
|
+
stub._lines = [b'{"type":"resp","id":1,"ok":true\n']
|
|
3699
3701
|
with pytest.raises(cli_module.ShuttleSerialError):
|
|
3700
3702
|
client._read()
|
|
3701
3703
|
stub._lines = [b'{"type":"resp","id":1,"ok":true,"seq":1}\n']
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#! /usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from shuttle import flash
|
|
10
|
+
from shuttle.firmware import DEFAULT_BOARD
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_list_available_boards_includes_default():
|
|
14
|
+
assert DEFAULT_BOARD in flash.list_available_boards()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_load_firmware_manifest_returns_segments():
|
|
18
|
+
manifest, package = flash.load_firmware_manifest("esp32c5")
|
|
19
|
+
assert manifest["segments"]
|
|
20
|
+
assert package.endswith("esp32c5")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_flash_firmware_invokes_esptool(monkeypatch):
|
|
24
|
+
calls = []
|
|
25
|
+
|
|
26
|
+
def fake_run(args):
|
|
27
|
+
calls.append(args)
|
|
28
|
+
|
|
29
|
+
monkeypatch.setattr(flash, "_run_esptool", fake_run)
|
|
30
|
+
|
|
31
|
+
manifest = flash.flash_firmware(
|
|
32
|
+
port="/dev/ttyUSB0",
|
|
33
|
+
baudrate=921600,
|
|
34
|
+
board="esp32c5",
|
|
35
|
+
erase_first=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
assert manifest["label"]
|
|
39
|
+
assert len(calls) == 2 # erase + write
|
|
40
|
+
erase_args, write_args = calls
|
|
41
|
+
assert erase_args[:4] == ["--chip", manifest["chip"], "--port", "/dev/ttyUSB0"]
|
|
42
|
+
assert "write-flash" in write_args
|
|
43
|
+
assert any("devboard.ino.bin" in arg for arg in write_args)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_flash_firmware_unknown_board():
|
|
47
|
+
with pytest.raises(flash.FirmwareFlashError):
|
|
48
|
+
flash.flash_firmware(port="/dev/null", baudrate=921600, board="does-not-exist")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_load_firmware_manifest_missing_file(monkeypatch, tmp_path):
|
|
52
|
+
board = "missing"
|
|
53
|
+
pkg_dir = tmp_path / "shuttle.firmware" / board
|
|
54
|
+
pkg_dir.mkdir(parents=True)
|
|
55
|
+
|
|
56
|
+
original_files = flash.resources.files
|
|
57
|
+
|
|
58
|
+
def fake_files(package):
|
|
59
|
+
if package == f"shuttle.firmware.{board}":
|
|
60
|
+
return pkg_dir
|
|
61
|
+
return original_files(package)
|
|
62
|
+
|
|
63
|
+
monkeypatch.setattr(flash.resources, "files", fake_files)
|
|
64
|
+
|
|
65
|
+
with pytest.raises(flash.FirmwareFlashError, match="manifest missing"):
|
|
66
|
+
flash.load_firmware_manifest(board)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_load_firmware_manifest_corrupt(monkeypatch, tmp_path):
|
|
70
|
+
board = "corrupt"
|
|
71
|
+
pkg_dir = tmp_path / "shuttle.firmware" / board
|
|
72
|
+
pkg_dir.mkdir(parents=True)
|
|
73
|
+
(pkg_dir / "manifest.json").write_text("not-json", encoding="utf-8")
|
|
74
|
+
|
|
75
|
+
original_files = flash.resources.files
|
|
76
|
+
|
|
77
|
+
def fake_files(package):
|
|
78
|
+
if package == f"shuttle.firmware.{board}":
|
|
79
|
+
return pkg_dir
|
|
80
|
+
return original_files(package)
|
|
81
|
+
|
|
82
|
+
monkeypatch.setattr(flash.resources, "files", fake_files)
|
|
83
|
+
|
|
84
|
+
with pytest.raises(flash.FirmwareFlashError, match="Invalid manifest"):
|
|
85
|
+
flash.load_firmware_manifest(board)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_load_firmware_manifest_missing_segments(monkeypatch, tmp_path):
|
|
89
|
+
board = "no-segments"
|
|
90
|
+
pkg_dir = tmp_path / "shuttle.firmware" / board
|
|
91
|
+
pkg_dir.mkdir(parents=True)
|
|
92
|
+
(pkg_dir / "manifest.json").write_text("{}", encoding="utf-8")
|
|
93
|
+
|
|
94
|
+
original_files = flash.resources.files
|
|
95
|
+
|
|
96
|
+
def fake_files(package):
|
|
97
|
+
if package == f"shuttle.firmware.{board}":
|
|
98
|
+
return pkg_dir
|
|
99
|
+
return original_files(package)
|
|
100
|
+
|
|
101
|
+
monkeypatch.setattr(flash.resources, "files", fake_files)
|
|
102
|
+
|
|
103
|
+
with pytest.raises(flash.FirmwareFlashError, match="defines no segments"):
|
|
104
|
+
flash.load_firmware_manifest(board)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.parametrize("bad_segment", [{"file": "foo.bin"}, {"offset": "0x0"}])
|
|
108
|
+
def test_flash_firmware_segment_missing_fields(monkeypatch, bad_segment):
|
|
109
|
+
manifest = {
|
|
110
|
+
"chip": "esp32c5",
|
|
111
|
+
"segments": [bad_segment],
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
monkeypatch.setattr(
|
|
115
|
+
flash, "load_firmware_manifest", lambda _board: (manifest, "pkg")
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
with pytest.raises(
|
|
119
|
+
flash.FirmwareFlashError,
|
|
120
|
+
match="Manifest segment entries require 'offset' and 'file'",
|
|
121
|
+
):
|
|
122
|
+
flash.flash_firmware(port="/dev/ttyUSB0", baudrate=921600, board="esp32c5")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_flash_firmware_missing_artifact(monkeypatch):
|
|
126
|
+
manifest = {
|
|
127
|
+
"chip": "esp32c5",
|
|
128
|
+
"segments": [{"offset": "0x0", "file": "missing.bin"}],
|
|
129
|
+
}
|
|
130
|
+
calls = []
|
|
131
|
+
tmp_pkg = Path("/nonexistent/pkg")
|
|
132
|
+
|
|
133
|
+
monkeypatch.setattr(
|
|
134
|
+
flash, "load_firmware_manifest", lambda _board: (manifest, "pkg")
|
|
135
|
+
)
|
|
136
|
+
monkeypatch.setattr(flash.resources, "files", lambda _pkg: tmp_pkg)
|
|
137
|
+
monkeypatch.setattr(
|
|
138
|
+
flash.resources,
|
|
139
|
+
"as_file",
|
|
140
|
+
lambda _traversable: (_ for _ in ()).throw(FileNotFoundError()),
|
|
141
|
+
)
|
|
142
|
+
monkeypatch.setattr(flash, "_run_esptool", lambda args: calls.append(args))
|
|
143
|
+
|
|
144
|
+
with pytest.raises(
|
|
145
|
+
flash.FirmwareFlashError, match="Missing firmware artifact: missing.bin"
|
|
146
|
+
):
|
|
147
|
+
flash.flash_firmware(port="/dev/ttyUSB0", baudrate=921600, board="esp32c5")
|
|
148
|
+
|
|
149
|
+
assert calls == []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_flash_firmware_adds_flash_options(monkeypatch, tmp_path):
|
|
153
|
+
artifact = tmp_path / "fw.bin"
|
|
154
|
+
artifact.write_bytes(b"fw")
|
|
155
|
+
manifest = {
|
|
156
|
+
"chip": "esp32c5",
|
|
157
|
+
"segments": [{"offset": "0x1000", "file": artifact.name}],
|
|
158
|
+
"flash-mode": "dout",
|
|
159
|
+
"flash-freq": "80m",
|
|
160
|
+
"flash-size": "2MB",
|
|
161
|
+
}
|
|
162
|
+
calls = []
|
|
163
|
+
|
|
164
|
+
monkeypatch.setattr(
|
|
165
|
+
flash, "load_firmware_manifest", lambda _board: (manifest, "pkg")
|
|
166
|
+
)
|
|
167
|
+
monkeypatch.setattr(flash.resources, "files", lambda _pkg: tmp_path)
|
|
168
|
+
|
|
169
|
+
@contextmanager
|
|
170
|
+
def fake_as_file(traversable):
|
|
171
|
+
yield traversable
|
|
172
|
+
|
|
173
|
+
monkeypatch.setattr(flash.resources, "as_file", fake_as_file)
|
|
174
|
+
monkeypatch.setattr(flash, "_run_esptool", lambda args: calls.append(args))
|
|
175
|
+
|
|
176
|
+
flash.flash_firmware(port="/dev/ttyUSB0", baudrate=921600, board="esp32c5")
|
|
177
|
+
|
|
178
|
+
assert len(calls) == 1
|
|
179
|
+
write_args = calls[0]
|
|
180
|
+
assert ["--flash-mode", "dout"] in [
|
|
181
|
+
write_args[i : i + 2] for i in range(len(write_args) - 1)
|
|
182
|
+
]
|
|
183
|
+
assert ["--flash-freq", "80m"] in [
|
|
184
|
+
write_args[i : i + 2] for i in range(len(write_args) - 1)
|
|
185
|
+
]
|
|
186
|
+
assert ["--flash-size", "2MB"] in [
|
|
187
|
+
write_args[i : i + 2] for i in range(len(write_args) - 1)
|
|
188
|
+
]
|
|
189
|
+
assert str(artifact) in write_args
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_flash_firmware_skips_missing_flash_options(monkeypatch, tmp_path):
|
|
193
|
+
artifact = tmp_path / "fw.bin"
|
|
194
|
+
artifact.write_bytes(b"fw")
|
|
195
|
+
manifest = {
|
|
196
|
+
"chip": "esp32c5",
|
|
197
|
+
"segments": [{"offset": "0x1000", "file": artifact.name}],
|
|
198
|
+
# no flash-mode/freq/size keys -> exercise falsy branch
|
|
199
|
+
}
|
|
200
|
+
calls = []
|
|
201
|
+
|
|
202
|
+
monkeypatch.setattr(
|
|
203
|
+
flash, "load_firmware_manifest", lambda _board: (manifest, "pkg")
|
|
204
|
+
)
|
|
205
|
+
monkeypatch.setattr(flash.resources, "files", lambda _pkg: tmp_path)
|
|
206
|
+
|
|
207
|
+
@contextmanager
|
|
208
|
+
def fake_as_file(traversable):
|
|
209
|
+
yield traversable
|
|
210
|
+
|
|
211
|
+
monkeypatch.setattr(flash.resources, "as_file", fake_as_file)
|
|
212
|
+
monkeypatch.setattr(flash, "_run_esptool", lambda args: calls.append(args))
|
|
213
|
+
|
|
214
|
+
flash.flash_firmware(port="/dev/ttyUSB0", baudrate=921600, board="esp32c5")
|
|
215
|
+
|
|
216
|
+
assert len(calls) == 1
|
|
217
|
+
write_args = calls[0]
|
|
218
|
+
assert all(
|
|
219
|
+
flag not in write_args
|
|
220
|
+
for flag in ("--flash-mode", "--flash-freq", "--flash-size")
|
|
221
|
+
)
|
|
222
|
+
assert str(artifact) in write_args
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_flash_firmware_handles_no_compress(monkeypatch):
|
|
226
|
+
calls = []
|
|
227
|
+
|
|
228
|
+
def fake_run(args):
|
|
229
|
+
calls.append(args)
|
|
230
|
+
|
|
231
|
+
manifest, package = flash.load_firmware_manifest("esp32c5")
|
|
232
|
+
custom_manifest = dict(manifest)
|
|
233
|
+
custom_manifest["compress"] = False
|
|
234
|
+
|
|
235
|
+
monkeypatch.setattr(flash, "_run_esptool", fake_run)
|
|
236
|
+
monkeypatch.setattr(
|
|
237
|
+
flash,
|
|
238
|
+
"load_firmware_manifest",
|
|
239
|
+
lambda _board: (custom_manifest, package),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
flash.flash_firmware(port="/dev/ttyUSB0", baudrate=921600)
|
|
243
|
+
|
|
244
|
+
assert len(calls) == 1
|
|
245
|
+
assert "--no-compress" in calls[0]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_run_esptool_success_with_exit_zero(monkeypatch):
|
|
249
|
+
recorded = []
|
|
250
|
+
|
|
251
|
+
def fake_main(args):
|
|
252
|
+
recorded.append(list(args))
|
|
253
|
+
raise SystemExit(0)
|
|
254
|
+
|
|
255
|
+
monkeypatch.setattr(flash.esptool, "main", fake_main)
|
|
256
|
+
|
|
257
|
+
flash._run_esptool(["ping"])
|
|
258
|
+
|
|
259
|
+
assert recorded[0] == ["ping"]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_run_esptool_failure(monkeypatch):
|
|
263
|
+
def fake_main(_args):
|
|
264
|
+
raise SystemExit(2)
|
|
265
|
+
|
|
266
|
+
monkeypatch.setattr(flash.esptool, "main", fake_main)
|
|
267
|
+
|
|
268
|
+
with pytest.raises(flash.FirmwareFlashError):
|
|
269
|
+
flash._run_esptool(["bad"])
|
|
@@ -398,4 +398,37 @@ def test_response_backlog_delivered(monkeypatch):
|
|
|
398
398
|
client._dispatch(json.loads(serial_obj.lines.get().decode()))
|
|
399
399
|
future = client.send_command("ping", {})
|
|
400
400
|
assert future.result(timeout=1)["ok"] is True
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def test_read_skips_noise_before_json():
|
|
404
|
+
class DummySerial:
|
|
405
|
+
def __init__(self):
|
|
406
|
+
self.lines = queue.Queue()
|
|
407
|
+
self.is_open = True
|
|
408
|
+
self.reset_calls = 0
|
|
409
|
+
|
|
410
|
+
def reset_input_buffer(self):
|
|
411
|
+
self.reset_calls += 1
|
|
412
|
+
|
|
413
|
+
def readline(self):
|
|
414
|
+
try:
|
|
415
|
+
return self.lines.get_nowait()
|
|
416
|
+
except queue.Empty:
|
|
417
|
+
return b""
|
|
418
|
+
|
|
419
|
+
def close(self):
|
|
420
|
+
self.is_open = False
|
|
421
|
+
|
|
422
|
+
serial_obj = DummySerial()
|
|
423
|
+
serial_obj.lines.put(b'":62606,"ok":true}\n')
|
|
424
|
+
serial_obj.lines.put(b'{"type":"resp","id":1,"ok":true}\n')
|
|
425
|
+
|
|
426
|
+
client = serial_client.NDJSONSerialClient.__new__(serial_client.NDJSONSerialClient)
|
|
427
|
+
client._serial = serial_obj
|
|
428
|
+
client._logger = None
|
|
429
|
+
client._seq_tracker = None
|
|
430
|
+
|
|
431
|
+
message = client._read()
|
|
432
|
+
assert message["ok"] is True
|
|
433
|
+
assert serial_obj.reset_calls >= 1
|
|
401
434
|
client.close()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import queue
|
|
2
|
+
import pytest
|
|
3
|
+
import shuttle.serial_client as serial_client
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DummySerial:
|
|
7
|
+
def __init__(self, data=b"", fail=False):
|
|
8
|
+
self._data = data
|
|
9
|
+
self._fail = fail
|
|
10
|
+
self.read_calls = 0
|
|
11
|
+
self.in_waiting = len(data)
|
|
12
|
+
self.is_open = True
|
|
13
|
+
self.reset_calls = 0
|
|
14
|
+
|
|
15
|
+
def read(self, n):
|
|
16
|
+
self.read_calls += 1
|
|
17
|
+
if self._fail:
|
|
18
|
+
raise Exception("fail")
|
|
19
|
+
d, self._data = self._data[:n], self._data[n:]
|
|
20
|
+
self.in_waiting = len(self._data)
|
|
21
|
+
return d
|
|
22
|
+
|
|
23
|
+
def reset_input_buffer(self):
|
|
24
|
+
self.reset_calls += 1
|
|
25
|
+
self._data = b""
|
|
26
|
+
self.in_waiting = 0
|
|
27
|
+
|
|
28
|
+
def close(self):
|
|
29
|
+
self.is_open = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DummyLogger:
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.logged = []
|
|
35
|
+
|
|
36
|
+
def log(self, direction, data):
|
|
37
|
+
self.logged.append((direction, data))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_flush_input_and_log_reads_and_logs(monkeypatch):
|
|
41
|
+
client = serial_client.NDJSONSerialClient.__new__(serial_client.NDJSONSerialClient)
|
|
42
|
+
logger = DummyLogger()
|
|
43
|
+
# Data in buffer
|
|
44
|
+
client._serial = DummySerial(b"abc123")
|
|
45
|
+
client._logger = logger
|
|
46
|
+
client.flush_input_and_log()
|
|
47
|
+
assert logger.logged == [("RX", b"abc123")]
|
|
48
|
+
# No data in buffer
|
|
49
|
+
client._serial = DummySerial(b"")
|
|
50
|
+
logger.logged.clear()
|
|
51
|
+
client.flush_input_and_log()
|
|
52
|
+
assert logger.logged == []
|
|
53
|
+
# Exception in read is handled
|
|
54
|
+
client._serial = DummySerial(b"abc", fail=True)
|
|
55
|
+
client.flush_input_and_log() # Should not raise
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_flush_input_and_log_no_serial():
|
|
59
|
+
client = serial_client.NDJSONSerialClient.__new__(serial_client.NDJSONSerialClient)
|
|
60
|
+
client._logger = DummyLogger()
|
|
61
|
+
client.flush_input_and_log() # Should not raise
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_flush_input_and_log_resets_buffer():
|
|
65
|
+
client = serial_client.NDJSONSerialClient.__new__(serial_client.NDJSONSerialClient)
|
|
66
|
+
serial = DummySerial(b"residual")
|
|
67
|
+
client._serial = serial
|
|
68
|
+
client._logger = DummyLogger()
|
|
69
|
+
client.flush_input_and_log()
|
|
70
|
+
assert serial.reset_calls == 1
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
#! /usr/bin/env python
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
|
|
4
|
-
import pytest
|
|
5
|
-
|
|
6
|
-
from shuttle import flash
|
|
7
|
-
from shuttle.firmware import DEFAULT_BOARD
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_list_available_boards_includes_default():
|
|
11
|
-
assert DEFAULT_BOARD in flash.list_available_boards()
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_load_firmware_manifest_returns_segments():
|
|
15
|
-
manifest, package = flash.load_firmware_manifest("esp32c5")
|
|
16
|
-
assert manifest["segments"]
|
|
17
|
-
assert package.endswith("esp32c5")
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_flash_firmware_invokes_esptool(monkeypatch):
|
|
21
|
-
calls = []
|
|
22
|
-
|
|
23
|
-
def fake_run(args):
|
|
24
|
-
calls.append(args)
|
|
25
|
-
|
|
26
|
-
monkeypatch.setattr(flash, "_run_esptool", fake_run)
|
|
27
|
-
|
|
28
|
-
manifest = flash.flash_firmware(
|
|
29
|
-
port="/dev/ttyUSB0",
|
|
30
|
-
baudrate=921600,
|
|
31
|
-
board="esp32c5",
|
|
32
|
-
erase_first=True,
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
assert manifest["label"]
|
|
36
|
-
assert len(calls) == 2 # erase + write
|
|
37
|
-
erase_args, write_args = calls
|
|
38
|
-
assert erase_args[:4] == ["--chip", manifest["chip"], "--port", "/dev/ttyUSB0"]
|
|
39
|
-
assert "write-flash" in write_args
|
|
40
|
-
assert any("devboard.ino.bin" in arg for arg in write_args)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def test_flash_firmware_unknown_board():
|
|
44
|
-
with pytest.raises(flash.FirmwareFlashError):
|
|
45
|
-
flash.flash_firmware(port="/dev/null", baudrate=921600, board="does-not-exist")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def test_flash_firmware_handles_no_compress(monkeypatch):
|
|
49
|
-
calls = []
|
|
50
|
-
|
|
51
|
-
def fake_run(args):
|
|
52
|
-
calls.append(args)
|
|
53
|
-
|
|
54
|
-
manifest, package = flash.load_firmware_manifest("esp32c5")
|
|
55
|
-
custom_manifest = dict(manifest)
|
|
56
|
-
custom_manifest["compress"] = False
|
|
57
|
-
|
|
58
|
-
monkeypatch.setattr(flash, "_run_esptool", fake_run)
|
|
59
|
-
monkeypatch.setattr(
|
|
60
|
-
flash,
|
|
61
|
-
"load_firmware_manifest",
|
|
62
|
-
lambda _board: (custom_manifest, package),
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
flash.flash_firmware(port="/dev/ttyUSB0", baudrate=921600)
|
|
66
|
-
|
|
67
|
-
assert len(calls) == 1
|
|
68
|
-
assert "--no-compress" in calls[0]
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def test_run_esptool_success_with_exit_zero(monkeypatch):
|
|
72
|
-
recorded = []
|
|
73
|
-
|
|
74
|
-
def fake_main(args):
|
|
75
|
-
recorded.append(list(args))
|
|
76
|
-
raise SystemExit(0)
|
|
77
|
-
|
|
78
|
-
monkeypatch.setattr(flash.esptool, "main", fake_main)
|
|
79
|
-
|
|
80
|
-
flash._run_esptool(["ping"])
|
|
81
|
-
|
|
82
|
-
assert recorded[0] == ["ping"]
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def test_run_esptool_failure(monkeypatch):
|
|
86
|
-
def fake_main(_args):
|
|
87
|
-
raise SystemExit(2)
|
|
88
|
-
|
|
89
|
-
monkeypatch.setattr(flash.esptool, "main", fake_main)
|
|
90
|
-
|
|
91
|
-
with pytest.raises(flash.FirmwareFlashError):
|
|
92
|
-
flash._run_esptool(["bad"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/devboard.ino.bootloader.bin
RENAMED
|
File without changes
|
{lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/devboard.ino.partitions.bin
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|