Skip to content

Commit 9bd4e20

Browse files
georgevigeletteCopilot
authored andcommitted
Fixes #432
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 87ea231 commit 9bd4e20

3 files changed

Lines changed: 184 additions & 24 deletions

File tree

src/openlifu/io/LIFUDFU.py

Lines changed: 164 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import logging
1414
import struct
1515
import time
16+
import unittest
1617
from typing import TYPE_CHECKING, Callable
1718

1819
from openlifu.io.LIFUConfig import OW_ERROR, OW_I2C_PASSTHRU
@@ -152,6 +153,134 @@ def parse_signed_package(pkg: bytes) -> dict:
152153
}
153154

154155

156+
# ---------------------------------------------------------------------------
157+
# Internal tests for DFU package parsing / CRC (deterministic, no hardware)
158+
# ---------------------------------------------------------------------------
159+
160+
def _build_synthetic_signed_package(
161+
fw: bytes,
162+
meta: bytes,
163+
fw_address: int = 0x08000000,
164+
meta_address: int = 0x08008000,
165+
) -> bytes:
166+
"""Construct a minimal, self-consistent signed DFU package for testing.
167+
168+
This uses the module's own header layout/CRC implementation so that tests
169+
validate :func:`stm32_crc32` and :func:`parse_signed_package` end to end.
170+
"""
171+
hdr_size = struct.calcsize(_PKG_HDR_FULL)
172+
payload = fw + meta
173+
fw_len = len(fw)
174+
meta_len = len(meta)
175+
176+
payload_crc = stm32_crc32(payload)
177+
178+
# First pack with a placeholder header CRC so we can compute the real one.
179+
header_crc_placeholder = 0
180+
header = struct.pack(
181+
_PKG_HDR_FULL,
182+
_PKG_MAGIC,
183+
_PKG_VERSION,
184+
hdr_size,
185+
fw_address,
186+
fw_len,
187+
meta_address,
188+
meta_len,
189+
payload_crc,
190+
header_crc_placeholder,
191+
)
192+
193+
header_crc = stm32_crc32(header[:-4])
194+
header = struct.pack(
195+
_PKG_HDR_FULL,
196+
_PKG_MAGIC,
197+
_PKG_VERSION,
198+
hdr_size,
199+
fw_address,
200+
fw_len,
201+
meta_address,
202+
meta_len,
203+
payload_crc,
204+
header_crc,
205+
)
206+
207+
return header + payload
208+
209+
210+
class TestSignedPackage(unittest.TestCase):
211+
"""Unit tests for :func:`stm32_crc32` and :func:`parse_signed_package`.
212+
213+
These tests are deterministic and require no hardware; they can be run by
214+
any standard Python test runner to guard against regressions that might
215+
otherwise risk bricking devices during DFU.
216+
"""
217+
218+
def test_parse_signed_package_valid(self) -> None:
219+
fw = b"\x01\x02\x03\x04"
220+
meta = b"\xAA\xBB"
221+
fw_addr = 0x08001000
222+
meta_addr = 0x08009000
223+
224+
pkg = _build_synthetic_signed_package(
225+
fw=fw,
226+
meta=meta,
227+
fw_address=fw_addr,
228+
meta_address=meta_addr,
229+
)
230+
231+
parsed = parse_signed_package(pkg)
232+
233+
assert parsed["fw_address"] == fw_addr
234+
assert parsed["meta_address"] == meta_addr
235+
assert parsed["fw"] == fw
236+
assert parsed["meta"] == meta
237+
238+
def test_parse_signed_package_header_crc_mismatch(self) -> None:
239+
"""Corrupt the header so header CRC verification fails."""
240+
fw = b"\x10\x20"
241+
meta = b"\x30"
242+
pkg = _build_synthetic_signed_package(fw=fw, meta=meta)
243+
244+
# Flip a bit inside the header (but keep magic/version/size plausible).
245+
pkg_bytes = bytearray(pkg)
246+
if len(pkg_bytes) < 8:
247+
self.skipTest("synthetic package unexpectedly small")
248+
pkg_bytes[4] ^= 0x01
249+
corrupted = bytes(pkg_bytes)
250+
251+
exc_msg = None
252+
try:
253+
parse_signed_package(corrupted)
254+
raise AssertionError("Expected ValueError was not raised")
255+
except ValueError as exc:
256+
exc_msg = str(exc)
257+
assert exc_msg is not None
258+
assert "header CRC mismatch" in exc_msg
259+
260+
def test_parse_signed_package_payload_crc_mismatch(self) -> None:
261+
"""Corrupt the payload so payload CRC verification fails."""
262+
fw = b"\xDE\xAD\xBE\xEF"
263+
meta = b"\x00\x01"
264+
pkg = _build_synthetic_signed_package(fw=fw, meta=meta)
265+
266+
hdr_size = struct.calcsize(_PKG_HDR_FULL)
267+
pkg_bytes = bytearray(pkg)
268+
# Flip a bit in the first payload byte (after the header).
269+
if len(pkg_bytes) <= hdr_size:
270+
self.skipTest("synthetic package unexpectedly small")
271+
pkg_bytes[hdr_size] ^= 0x01
272+
corrupted = bytes(pkg_bytes)
273+
274+
exc_msg = None
275+
try:
276+
parse_signed_package(corrupted)
277+
raise AssertionError("Expected ValueError was not raised")
278+
except ValueError as exc:
279+
exc_msg = str(exc)
280+
assert exc_msg is not None
281+
assert "payload CRC mismatch" in exc_msg
282+
283+
155284
# ---------------------------------------------------------------------------
156285
# USB DFU client (module 0)
157286
# ---------------------------------------------------------------------------
@@ -414,7 +543,6 @@ def __init__(self, uart: LIFUUart,
414543
write_read_delay_s: float = 0.005):
415544
self._uart = uart
416545
self._addr = i2c_addr
417-
self._wr_delay = write_read_delay_s
418546

419547
# --- low-level transport primitives ---
420548

@@ -437,11 +565,14 @@ def _write(self, payload: bytes) -> None:
437565

438566
def _exchange(self, payload: bytes, read_len: int,
439567
pre_read_delay_s: float | None = None) -> bytes:
440-
"""Write *payload* to the I2C slave, wait, then read *read_len* bytes back.
441-
442-
The firmware inserts a fixed 5 ms gap between write and read.
443-
An optional extra host-side delay can be added via *pre_read_delay_s*
444-
(not usually needed).
568+
"""Write *payload* to the I2C slave and read *read_len* bytes back.
569+
570+
The firmware executes a combined write+read transaction and inserts a
571+
fixed 5 ms gap between the write and read phases internally.
572+
The optional *pre_read_delay_s* parameter adds an extra host-side delay
573+
**before** issuing the passthrough transaction (i.e. before the
574+
firmware performs the write+read). This does *not* change the internal
575+
5 ms gap handled by the firmware and is rarely needed.
445576
"""
446577
if pre_read_delay_s and pre_read_delay_s > 0:
447578
time.sleep(pre_read_delay_s)
@@ -784,16 +915,35 @@ def update_module(self,
784915
"Verifying I2C DFU entry (module %d, addr=0x%02X via master)...",
785916
module, i2c_addr,
786917
)
787-
try:
788-
bl_version = self.get_bootloader_version_i2c(i2c_addr=i2c_addr)
789-
except (RuntimeError, TimeoutError) as e:
790-
raise RuntimeError(
791-
f"Module {module} did not enter I2C DFU mode at "
792-
f"0x{i2c_addr:02X}: {e}"
793-
) from e
918+
start_time = time.time()
919+
bl_version = None
920+
last_error: Exception | None = None
921+
while True:
922+
elapsed = time.time() - start_time
923+
if elapsed >= dfu_enum_timeout_s:
924+
break
925+
try:
926+
candidate = self.get_bootloader_version_i2c(i2c_addr=i2c_addr)
927+
if candidate:
928+
bl_version = candidate
929+
break
930+
# Treat empty version string as a failure worth retrying.
931+
last_error = RuntimeError(
932+
"I2C DFU bootloader returned an empty version string"
933+
)
934+
except (RuntimeError, TimeoutError) as e:
935+
last_error = e
936+
# Small delay before retrying to avoid busy-waiting.
937+
time.sleep(0.2)
794938
if not bl_version:
939+
if last_error is not None:
940+
raise RuntimeError(
941+
f"Module {module} did not enter I2C DFU mode at "
942+
f"0x{i2c_addr:02X} within {dfu_enum_timeout_s}s: {last_error}"
943+
) from last_error
795944
raise RuntimeError(
796-
f"Module {module} I2C DFU bootloader returned an empty version string"
945+
f"Module {module} did not enter I2C DFU mode at "
946+
f"0x{i2c_addr:02X} within {dfu_enum_timeout_s}s"
797947
)
798948
logger.info("I2C DFU bootloader version: %s", bl_version)
799949
self.program_i2c(

src/openlifu/io/LIFUTXDevice.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ def get_hardware_id(self, module:int=0) -> str:
397397
logger.error("Unexpected error during process: %s", e)
398398
raise # Re-raise the exception for the caller to handle
399399

400-
def read_config(self, module:int=0) -> Optional[LifuUserConfig]:
400+
def read_config(self, module:int=0) -> LifuUserConfig | None:
401401
"""
402402
Read the user configuration from device flash.
403403
@@ -450,7 +450,7 @@ def read_config(self, module:int=0) -> Optional[LifuUserConfig]:
450450
logger.exception("Unexpected error reading config")
451451
raise
452452

453-
def write_config(self, config: LifuUserConfig, module:int=0) -> Optional[LifuUserConfig]:
453+
def write_config(self, config: LifuUserConfig, module:int=0) -> LifuUserConfig | None:
454454
"""
455455
Write user configuration to device flash.
456456
@@ -517,7 +517,7 @@ def write_config(self, config: LifuUserConfig, module:int=0) -> Optional[LifuUse
517517
logger.exception("Unexpected error writing config")
518518
raise
519519

520-
def write_config_json(self, json_str: str, module:int=0) -> Optional[LifuUserConfig]:
520+
def write_config_json(self, json_str: str, module:int=0) -> LifuUserConfig | None:
521521
"""
522522
Write user configuration from a JSON string.
523523
@@ -540,6 +540,13 @@ def write_config_json(self, json_str: str, module:int=0) -> Optional[LifuUserCon
540540
return self.write_config(module=module, config=config)
541541
except json.JSONDecodeError as e:
542542
logger.error(f"Invalid JSON: {e}")
543+
raise ValueError(f"Invalid JSON: {e}") from e
544+
except ValueError as v:
545+
logger.error("ValueError: %s", v)
546+
raise
547+
except (OSError, RuntimeError, AttributeError) as e:
548+
logger.exception("Unexpected error writing config from JSON")
549+
raise
543550

544551
def get_temperature(self, module:int=1) -> float:
545552
"""
@@ -935,7 +942,7 @@ def enter_dfu(self, module:int=0) -> bool:
935942
if not self.uart.is_connected():
936943
raise ValueError("TX Device not connected")
937944

938-
r = self.uart.send_packet(id=None, packetType=OW_CONTROLLER, command=OW_CMD_DFU, addr=module)
945+
r = self.uart.send_packet(id=None, packetType=OW_CMD, command=OW_CMD_DFU, addr=module)
939946
self.uart.clear_buffer()
940947
if r is None:
941948
# Device disconnected immediately after reset — expected for DFU entry
@@ -982,7 +989,7 @@ def async_mode(self, enable: bool | None = None) -> bool:
982989
else:
983990
payload = None
984991

985-
r = self.uart.send_packet(id=None, packetType=OW_CONTROLLER, command=OW_CMD_ASYNC, addr=0, data=payload)
992+
r = self.uart.send_packet(id=None, packetType=OW_CMD, command=OW_CMD_ASYNC, addr=0, data=payload)
986993
self.uart.clear_buffer()
987994
# r.print_packet()
988995
if r.packet_type == OW_ERROR:
@@ -1329,11 +1336,10 @@ def write_block(self, identifier: int, start_address: int, reg_values: List[int]
13291336
raise # Re-raise the exception for the caller to handle
13301337

13311338
except Exception as e:
1332-
logger.error("Unexpected error during process: %s", e)
1333-
raise # Re-raise the exception for the caller to handleected error in write_block: {e}")
1334-
return False
1339+
logger.error("Unexpected error in write_block: %s", e)
1340+
raise # Re-raise the exception for the caller to handle
13351341

1336-
def read_block(self, identifier: int, start_address: int, count: int) -> Optional[List[int]]:
1342+
def read_block(self, identifier: int, start_address: int, count: int) -> List[int] | None:
13371343
"""
13381344
Read a block of consecutive register values from the TX device.
13391345

src/openlifu/io/LIFUUserConfig.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
import json
44
import logging
55
import struct
6+
import zlib
67
from dataclasses import dataclass
78
from typing import Any, Dict
89

910
logger = logging.getLogger(__name__)
1011

1112
# Constants from C code
1213
LIFU_MAGIC = 0x4C494655 # 'LIFU'
13-
LIFU_VER = 0x00010002 # v1.0.0
14+
LIFU_VER = 0x00010002 # v1.0.2
1415

1516
@dataclass
1617
class LifuUserConfigHeader:
@@ -139,6 +140,9 @@ def to_wire_bytes(self) -> bytes:
139140
# Update header with JSON length
140141
self.header.json_len = len(json_bytes)
141142

143+
# Update header CRC based on JSON payload (16-bit from CRC32)
144+
self.header.crc = zlib.crc32(json_bytes) & 0xFFFF
145+
142146
# Build wire format
143147
return self.header.to_bytes() + json_bytes
144148

0 commit comments

Comments
 (0)