Skip to content

Commit c63de55

Browse files
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 596e101 commit c63de55

3 files changed

Lines changed: 174 additions & 24 deletions

File tree

src/openlifu/io/LIFUDFU.py

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

19+
import pytest
20+
1821
from openlifu.io.LIFUConfig import OW_ERROR, OW_I2C_PASSTHRU
1922

2023
if TYPE_CHECKING:
@@ -152,6 +155,122 @@ def parse_signed_package(pkg: bytes) -> dict:
152155
}
153156

154157

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

419537
# --- low-level transport primitives ---
420538

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

438556
def _exchange(self, payload: bytes, read_len: int,
439557
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).
558+
"""Write *payload* to the I2C slave and read *read_len* bytes back.
559+
560+
The firmware executes a combined write+read transaction and inserts a
561+
fixed 5 ms gap between the write and read phases internally.
562+
The optional *pre_read_delay_s* parameter adds an extra host-side delay
563+
**before** issuing the passthrough transaction (i.e. before the
564+
firmware performs the write+read). This does *not* change the internal
565+
5 ms gap handled by the firmware and is rarely needed.
445566
"""
446567
if pre_read_delay_s and pre_read_delay_s > 0:
447568
time.sleep(pre_read_delay_s)
@@ -784,16 +905,35 @@ def update_module(self,
784905
"Verifying I2C DFU entry (module %d, addr=0x%02X via master)...",
785906
module, i2c_addr,
786907
)
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
908+
start_time = time.time()
909+
bl_version = None
910+
last_error: Exception | None = None
911+
while True:
912+
elapsed = time.time() - start_time
913+
if elapsed >= dfu_enum_timeout_s:
914+
break
915+
try:
916+
candidate = self.get_bootloader_version_i2c(i2c_addr=i2c_addr)
917+
if candidate:
918+
bl_version = candidate
919+
break
920+
# Treat empty version string as a failure worth retrying.
921+
last_error = RuntimeError(
922+
"I2C DFU bootloader returned an empty version string"
923+
)
924+
except (RuntimeError, TimeoutError) as e:
925+
last_error = e
926+
# Small delay before retrying to avoid busy-waiting.
927+
time.sleep(0.2)
794928
if not bl_version:
929+
if last_error is not None:
930+
raise RuntimeError(
931+
f"Module {module} did not enter I2C DFU mode at "
932+
f"0x{i2c_addr:02X} within {dfu_enum_timeout_s}s: {last_error}"
933+
) from last_error
795934
raise RuntimeError(
796-
f"Module {module} I2C DFU bootloader returned an empty version string"
935+
f"Module {module} did not enter I2C DFU mode at "
936+
f"0x{i2c_addr:02X} within {dfu_enum_timeout_s}s"
797937
)
798938
logger.info("I2C DFU bootloader version: %s", bl_version)
799939
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)