Skip to content

Commit 9db5b2c

Browse files
authored
feat: Add pyodide bridge (for webUSB usage) and tests (ai-slop) (#11)
1 parent d6ca943 commit 9db5b2c

6 files changed

Lines changed: 704 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,13 @@ dependencies = [
2020
[dependency-groups]
2121
dev = [
2222
"pyinstaller>=6.19.0",
23+
"pytest>=8.3.0",
2324
]
2425

26+
[tool.pytest.ini_options]
27+
testpaths = ["tests"]
28+
pythonpath = ["src"]
29+
2530
[project.scripts]
2631
rfunit-cli = "rfunit:main"
2732
rfunit-gui = "rfunit_gui:main"

src/vpe_pyodide_bridge.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
Pyodide-facing glue for vpe.py.
3+
4+
Runs inside Pyodide (in-browser CPython), called from
5+
cmsis-dap-webusb/src/vpe/pyodide-runtime.ts via pyodide.runPythonAsync(). Not
6+
a TS port - this is Python that calls vpe.py's existing, unmodified classes
7+
directly, so cmsis-dap-webusb's sound-editor step reuses the real codec
8+
implementation rather than re-deriving it.
9+
10+
Every function here takes/returns plain paths and JSON-serializable values
11+
(no vpe.py dataclass instances cross this boundary), using Pyodide's
12+
in-memory FS (pyodide.FS.writeFile/readFile on the TS side) the same way the
13+
CLI in vpe.py's own __main__ uses the real filesystem - see vpe.py's
14+
`main()` for the equivalent path-based CLI workflow this mirrors.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import os
20+
21+
from vpe import (
22+
ENCODING_PRESETS,
23+
AudioSegment,
24+
EncodingProfile,
25+
ISD9160Firmware,
26+
SirenEncoder,
27+
)
28+
29+
30+
def extract_segments(fw_path: str, output_dir: str) -> list[dict]:
31+
"""Extract every segment in the firmware at fw_path to WAV+raw files
32+
under output_dir (via ISD9160Firmware.extract_all). Returns per-segment
33+
metadata the UI needs to build a segment list."""
34+
fw = ISD9160Firmware.from_filepath(fw_path)
35+
fw.extract_all(output_dir)
36+
37+
results = []
38+
for idx in range(fw.segment_count):
39+
seg = fw.get_segment(idx)
40+
codec_name = seg.codec.name
41+
wav_name = f"segment_{idx:02d}_{codec_name}.wav"
42+
raw_name = f"segment_{idx:02d}_{codec_name}.raw"
43+
wav_path = os.path.join(output_dir, wav_name)
44+
# extract_all() catches per-segment decode failures and simply skips
45+
# writing a usable WAV for that segment (see vpe.py's extract_all) -
46+
# report whether one actually landed rather than assuming it did.
47+
has_wav = os.path.exists(wav_path) and os.path.getsize(wav_path) > 0
48+
results.append(
49+
{
50+
"index": idx,
51+
"codec": codec_name,
52+
"wavFile": wav_name if has_wav else None,
53+
"rawFile": raw_name,
54+
}
55+
)
56+
return results
57+
58+
59+
def encode_wav_into_segment(fw_path: str, wav_path: str, profile_name: str = "32 kHz / 48 kbps Siren14") -> bytes:
60+
"""Encode wav_path into a Siren-compressed segment using the lookup
61+
tables embedded in the firmware at fw_path, returning raw segment bytes
62+
ready to be handed to patch_firmware()'s segment_updates."""
63+
if profile_name not in ENCODING_PRESETS:
64+
raise ValueError(f"Unknown encoding profile {profile_name!r}. Available: {list(ENCODING_PRESETS)}")
65+
profile: EncodingProfile = ENCODING_PRESETS[profile_name]
66+
67+
fw = ISD9160Firmware.from_filepath(fw_path)
68+
encoder = SirenEncoder(fw.data)
69+
segment = encoder.encode_wav_into_audio_segment(wav_path, profile)
70+
return segment.data
71+
72+
73+
def patch_firmware(fw_path: str, segment_paths: dict[int, str], version_str: str, output_path: str) -> None:
74+
"""Replace the given segment indices with the raw segment bytes found at
75+
segment_paths[index] (a path, not inline bytes - avoids the caller
76+
having to embed potentially large binary payloads as Python source text
77+
when driving this through pyodide.runPythonAsync()), set a new version
78+
string, rehash, and write the patched firmware to output_path.
79+
80+
NOTE: writes output_path itself rather than returning ISD9160Firmware.data
81+
directly, and deliberately does not return the ISD9160Firmware object
82+
patch_with_new_segments() produces - its `.version`/`.seg_entries` stay
83+
stale after patching (see external/DuRFUnitI2C/tests/test_vpe.py's
84+
documented gotcha), only `.data` is trustworthy. Read output_path back
85+
via a fresh ISD9160Firmware.from_filepath() call if you need to inspect
86+
the result afterwards.
87+
"""
88+
fw = ISD9160Firmware.from_filepath(fw_path)
89+
new_segments = fw.get_all_segments()
90+
91+
for index, seg_path in segment_paths.items():
92+
idx = int(index)
93+
if idx < 0 or idx >= len(new_segments):
94+
raise IndexError(f"Segment index {idx} out of range (firmware has {len(new_segments)} segments)")
95+
with open(seg_path, "rb") as f:
96+
new_segments[idx] = AudioSegment(f.read())
97+
98+
patched = fw.patch_with_new_segments(new_segments, version_str)
99+
100+
with open(output_path, "wb") as f:
101+
f.write(patched.data)

tests/test_rfunit_protocol.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""
2+
Protocol-framing tests for rfunit.RfUnitI2C.
3+
4+
These pin down the exact wire format (command bytes, status-polling state
5+
machine, chunking math) so the TypeScript port in cmsis-dap-webusb/src/i2c/
6+
can be checked byte-for-byte against the same fixtures. The golden vectors
7+
below (especially gen_challenge_response) are copied verbatim into
8+
src/i2c/rfunit-i2c.test.ts - if you change rfunit.py's protocol, update both.
9+
"""
10+
11+
import struct
12+
13+
import pytest
14+
15+
import rfunit
16+
17+
18+
class RecordingI2CClient(rfunit.I2CClient):
19+
"""Fake transport that records every write/transmit call and returns
20+
pre-programmed responses, queued in call order. Falls back to
21+
zero-filled responses once the queue is exhausted."""
22+
23+
def __init__(self, read_responses=None, transmit_responses=None):
24+
self.writes: list[list[int]] = []
25+
self.transmits: list[tuple[list[int], int]] = []
26+
self.read_lens: list[int] = []
27+
self._read_responses = list(read_responses or [])
28+
self._transmit_responses = list(transmit_responses or [])
29+
30+
def scan(self):
31+
return [rfunit.I2C_ADDR]
32+
33+
def read(self, read_len: int):
34+
self.read_lens.append(read_len)
35+
if self._read_responses:
36+
return self._read_responses.pop(0)
37+
return [0] * read_len
38+
39+
def write(self, data):
40+
self.writes.append(list(data))
41+
42+
def transmit(self, data, read_len: int):
43+
self.transmits.append((list(data), read_len))
44+
if self._transmit_responses:
45+
return self._transmit_responses.pop(0)
46+
return [0] * read_len
47+
48+
49+
# ---------------------------------------------------------------------------
50+
# gen_challenge_response golden vectors
51+
# ---------------------------------------------------------------------------
52+
53+
CHALLENGE_RESPONSE_VECTORS = [
54+
([0, 0, 0, 0], [0x85, 0x44, 0xE5, 0x2E]),
55+
([3, 17, 250, 8], [0xC5, 0x53, 0x6F, 0x44]),
56+
([255, 255, 255, 255], [0x15, 0x2A, 0x0F, 0x3A]),
57+
([1, 2, 3, 4], [0x05, 0x1E, 0x40, 0x36]),
58+
]
59+
60+
61+
@pytest.mark.parametrize("challenge,expected", CHALLENGE_RESPONSE_VECTORS)
62+
def test_gen_challenge_response_golden_vectors(challenge, expected):
63+
assert rfunit.gen_challenge_response(challenge) == expected
64+
65+
66+
def test_gen_challenge_response_applies_modulo_11_to_each_byte():
67+
# 11 (0xB) and 22 both reduce to 0 mod 11 - response must be identical.
68+
assert rfunit.gen_challenge_response([11, 11, 11, 11]) == rfunit.gen_challenge_response([22, 22, 22, 22])
69+
assert rfunit.gen_challenge_response([0, 0, 0, 0]) == rfunit.gen_challenge_response([11, 22, 33, 44])
70+
71+
72+
# ---------------------------------------------------------------------------
73+
# Command byte-framing
74+
# ---------------------------------------------------------------------------
75+
76+
77+
def test_read_register_frames_command_and_slices_status_bytes():
78+
dev = RecordingI2CClient(transmit_responses=[[0xAA, 0xBB, 1, 2, 3, 4]])
79+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
80+
81+
result = rf.read_register(0x0C)
82+
83+
assert dev.transmits == [([rfunit.CMD_REG_READ_xC1, 0x0C], 6)]
84+
assert result == [1, 2, 3, 4]
85+
86+
87+
def test_write_register_frames_command_register_and_data():
88+
dev = RecordingI2CClient()
89+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
90+
91+
rf.write_register(0x0C, [0x01, 0x02])
92+
93+
assert dev.writes == [[rfunit.CMD_REG_WRITE_x48, 0x0C, 0x01, 0x02]]
94+
95+
96+
def test_read_data_frames_address_as_little_endian_u32_and_slices_response():
97+
dev = RecordingI2CClient(transmit_responses=[[0xAA, 0xBB, 1, 2, 3, 4, 5, 6, 0xFF]])
98+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
99+
100+
result = rf.read_data(0x00001234)
101+
102+
expected_cmd = [rfunit.CMD_FLASH_READ_xC3, *struct.pack("<I", 0x00001234)]
103+
assert dev.transmits == [(expected_cmd, 8)]
104+
# read_data returns a bytes slice (rfunit.py:349), not a list.
105+
assert result == bytes([1, 2, 3, 4, 5, 6])
106+
107+
108+
def test_erase_flash_frames_addr_and_count_as_little_endian_u32(monkeypatch):
109+
dev = RecordingI2CClient()
110+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
111+
monkeypatch.setattr(rf, "wait_busy", lambda: True)
112+
113+
rf.erase_flash(0x1000, 0x24400)
114+
115+
expected = [
116+
rfunit.CMD_FLASH_ERASE_x95,
117+
*struct.pack("<I", 0x1000),
118+
*struct.pack("<I", 0x24400),
119+
]
120+
assert dev.writes == [expected]
121+
122+
123+
def test_write_flash_sets_address_then_writes_data(monkeypatch):
124+
dev = RecordingI2CClient()
125+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
126+
monkeypatch.setattr(rf, "wait_busy", lambda: True)
127+
128+
rf.write_flash(0x2000, b"\x01\x02\x03")
129+
130+
assert dev.writes == [
131+
[rfunit.CMD_FLASH_SET_WRITE_ADDR_x9B, *struct.pack("<I", 0x2000)],
132+
[rfunit.CMD_FLASH_WRITE_x9A, 1, 2, 3],
133+
]
134+
135+
136+
def test_write_flash_aborts_before_data_write_if_set_address_wait_busy_fails(monkeypatch):
137+
dev = RecordingI2CClient()
138+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
139+
monkeypatch.setattr(rf, "wait_busy", lambda: False)
140+
141+
result = rf.write_flash(0x2000, b"\x01\x02\x03")
142+
143+
assert result is False
144+
# Only the "set address" write happened - not the data write.
145+
assert len(dev.writes) == 1
146+
147+
148+
def test_boot_to_ldrom_sends_challenge_response_command(monkeypatch):
149+
dev = RecordingI2CClient()
150+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
151+
monkeypatch.setattr(rf, "get_timer_value", lambda: [0, 0, 0, 0])
152+
monkeypatch.setattr(rf, "init", lambda: None)
153+
monkeypatch.setattr(rf, "stop", lambda: None)
154+
monkeypatch.setattr(rf, "wait_for_status", lambda target: True)
155+
156+
# boot_to_ldrom busy-waits on a real 10-second time.time()-based clock
157+
# (rfunit.py:378-381 - needed on micropython to avoid a serial timeout).
158+
# Fake an advancing clock so the test doesn't block for 10 real seconds
159+
# (or hang forever if time.time() were naively frozen).
160+
fake_clock = [0.0]
161+
monkeypatch.setattr(rfunit.time, "time", lambda: fake_clock[0])
162+
monkeypatch.setattr(rfunit.time, "sleep", lambda _s: fake_clock.__setitem__(0, fake_clock[0] + 1))
163+
164+
result = rf.boot_to_ldrom()
165+
166+
expected = [rfunit.CMD_BOOT_LDROM_x4B, *rfunit.gen_challenge_response([0, 0, 0, 0])]
167+
assert dev.writes == [expected]
168+
assert result is True
169+
170+
171+
# ---------------------------------------------------------------------------
172+
# wait_busy / wait_for_status state machine
173+
# ---------------------------------------------------------------------------
174+
175+
176+
def _status_client(statuses):
177+
"""A RecordingI2CClient whose read(2) calls step through `statuses`
178+
(each a full u16, little-endian-packed on read like the real status
179+
register), holding the last value once exhausted."""
180+
181+
class _StatusClient(RecordingI2CClient):
182+
def read(self, read_len: int):
183+
self.read_lens.append(read_len)
184+
value = statuses[min(len(self.read_lens) - 1, len(statuses) - 1)]
185+
return list(struct.pack("<H", value))
186+
187+
return _StatusClient()
188+
189+
190+
@pytest.fixture(autouse=True)
191+
def _no_real_sleep(monkeypatch):
192+
monkeypatch.setattr(rfunit.time, "sleep", lambda _s: None)
193+
194+
195+
def test_wait_busy_returns_true_once_status_leaves_busy():
196+
dev = _status_client([rfunit.STATUS_BUSY, rfunit.STATUS_BUSY, rfunit.STATUS_READY])
197+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
198+
199+
assert rf.wait_busy() is True
200+
201+
202+
def test_wait_busy_returns_false_on_error_outside_ldrom():
203+
dev = _status_client([rfunit.STATUS_ERROR])
204+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
205+
rf.read_error_string = lambda: b"boom"
206+
207+
assert rf.wait_busy() is False
208+
209+
210+
def test_wait_busy_ignores_error_flag_while_in_ldrom():
211+
# STATUS_LDROM (0x0C) has the STATUS_ERROR (0x04) bit set too - the
212+
# real firmware always reports "error" while in LDROM, so wait_busy
213+
# must NOT treat this as a failure (rfunit.py's documented special case).
214+
dev = _status_client([rfunit.STATUS_LDROM])
215+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
216+
217+
assert rf.wait_busy() is True
218+
219+
220+
def test_wait_busy_passes_through_boot_ldrom_in_progress_then_succeeds():
221+
dev = _status_client(
222+
[rfunit.STATUS_BOOT_LDROM_IN_PROGRESS, rfunit.STATUS_BOOT_LDROM_IN_PROGRESS, rfunit.STATUS_READY]
223+
)
224+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
225+
226+
assert rf.wait_busy() is True
227+
228+
229+
def test_wait_for_status_returns_true_when_target_bit_is_set():
230+
dev = _status_client([rfunit.STATUS_BUSY, rfunit.STATUS_LDROM])
231+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
232+
233+
assert rf.wait_for_status(rfunit.STATUS_LDROM) is True
234+
235+
236+
def test_wait_for_status_returns_false_on_unknown_status():
237+
dev = _status_client([0x40]) # not busy, not error, not the boot-in-progress sentinel
238+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
239+
240+
assert rf.wait_for_status(rfunit.STATUS_READY) is False
241+
242+
243+
def test_wait_for_status_returns_false_on_error_outside_ldrom():
244+
dev = _status_client([rfunit.STATUS_ERROR])
245+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
246+
rf.read_error_string = lambda: b"boom"
247+
248+
assert rf.wait_for_status(rfunit.STATUS_READY) is False
249+
250+
251+
# ---------------------------------------------------------------------------
252+
# dump_flash chunking
253+
# ---------------------------------------------------------------------------
254+
255+
256+
def test_dump_flash_yields_six_byte_chunks():
257+
dev = RecordingI2CClient()
258+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
259+
rf.read_data = lambda addr: [addr & 0xFF] * 6
260+
261+
chunks = list(rf.dump_flash(0, 18))
262+
263+
assert len(chunks) == 3
264+
assert all(len(c) == 6 for c in chunks)
265+
266+
267+
def test_dump_flash_truncates_final_partial_chunk():
268+
dev = RecordingI2CClient()
269+
rf = rfunit.RfUnitI2C(dev, logger=lambda *_: None)
270+
rf.read_data = lambda addr: [1, 2, 3, 4, 5, 6]
271+
272+
# 20 bytes total: two full 6-byte chunks + one 6-byte chunk truncated to 2.
273+
chunks = list(rf.dump_flash(0, 14))
274+
275+
assert [len(c) for c in chunks] == [6, 6, 2]
276+
assert chunks[-1] == [1, 2]

0 commit comments

Comments
 (0)