1313import logging
1414import struct
1515import time
16+ import unittest
1617from typing import TYPE_CHECKING , Callable
1718
19+ import pytest
20+
1821from openlifu .io .LIFUConfig import OW_ERROR , OW_I2C_PASSTHRU
1922
2023if 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 (
0 commit comments