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.

Files changed (36) hide show
  1. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/PKG-INFO +1 -1
  2. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/pyproject.toml +1 -1
  3. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/PKG-INFO +1 -1
  4. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/SOURCES.txt +1 -0
  5. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/cli.py +42 -0
  6. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/devboard.ino.bin +0 -0
  7. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/serial_client.py +64 -23
  8. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_cli.py +3 -1
  9. lr_shuttle-0.2.10/tests/test_flash.py +269 -0
  10. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_serial_client.py +33 -0
  11. lr_shuttle-0.2.10/tests/test_serial_client_flush.py +70 -0
  12. lr_shuttle-0.2.8/tests/test_flash.py +0 -92
  13. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/README.md +0 -0
  14. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/setup.cfg +0 -0
  15. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/dependency_links.txt +0 -0
  16. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/entry_points.txt +0 -0
  17. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/requires.txt +0 -0
  18. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/lr_shuttle.egg-info/top_level.txt +0 -0
  19. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/constants.py +0 -0
  20. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/__init__.py +0 -0
  21. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/__init__.py +0 -0
  22. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/boot_app0.bin +0 -0
  23. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/devboard.ino.bootloader.bin +0 -0
  24. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/devboard.ino.partitions.bin +0 -0
  25. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/firmware/esp32c5/manifest.json +0 -0
  26. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/flash.py +0 -0
  27. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/prodtest.py +0 -0
  28. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/src/shuttle/timo.py +0 -0
  29. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_cli_client.py +0 -0
  30. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_cli_edge.py +0 -0
  31. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_cli_seq.py +0 -0
  32. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_cli_utils.py +0 -0
  33. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_prodtest_edge.py +0 -0
  34. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_prodtest_helpers.py +0 -0
  35. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_timo.py +0 -0
  36. {lr_shuttle-0.2.8 → lr_shuttle-0.2.10}/tests/test_timo_write.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lr-shuttle
3
- Version: 0.2.8
3
+ Version: 0.2.10
4
4
  Summary: CLI and Python client for host-side of json based serial communication with embedded device bridge.
5
5
  Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lr-shuttle"
7
- version = "0.2.8"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lr-shuttle
3
- Version: 0.2.8
3
+ Version: 0.2.10
4
4
  Summary: CLI and Python client for host-side of json based serial communication with embedded device bridge.
5
5
  Author-email: Jonas Estberger <jonas.estberger@lumenradio.com>
6
6
  License: MIT
@@ -28,5 +28,6 @@ tests/test_flash.py
28
28
  tests/test_prodtest_edge.py
29
29
  tests/test_prodtest_helpers.py
30
30
  tests/test_serial_client.py
31
+ tests/test_serial_client_flush.py
31
32
  tests/test_timo.py
32
33
  tests/test_timo_write.py
@@ -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}[/]"
@@ -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._serial.reset_input_buffer()
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
- try:
461
- line = self._serial.readline()
462
- except SerialException as exc: # pragma: no cover - hardware specific
463
- raise ShuttleSerialError(f"Serial read failed: {exc}") from exc
464
- if not line:
465
- return None
466
- self._log_serial("RX", line)
467
- stripped = line.strip()
468
- if not stripped:
469
- return None
470
- try:
471
- decoded = stripped.decode("utf-8")
472
- except UnicodeDecodeError as exc:
473
- raise ShuttleSerialError(f"Invalid UTF-8 from device: {exc}") from exc
474
- try:
475
- message = json.loads(decoded)
476
- except json.JSONDecodeError as exc:
477
- raise ShuttleSerialError(
478
- f"Invalid JSON from device: {decoded} ({exc})"
479
- ) from exc
480
- self._record_sequence(message)
481
- return message
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"not-json\n"]
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