From 11e59ba5c7ee9181a1a487dff63b255744cb6d69 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Tue, 16 Jun 2026 18:36:27 -0700 Subject: [PATCH 01/68] Add 2.0.3 triggering compatibility --- examples/test_tx_dfu.py | 2 +- src/openlifu_sdk/io/LIFUInterface.py | 147 +++++++++++++-------- src/openlifu_sdk/io/LIFUTXDevice.py | 46 ++++++- src/openlifu_sdk/io/component.py | 63 ++++++++- src/openlifu_sdk/io/exceptions.py | 27 +++- src/openlifu_sdk/ui/simulated_interface.py | 4 - unit-test/test_tx_device.py | 73 ++++++++++ 7 files changed, 295 insertions(+), 67 deletions(-) diff --git a/examples/test_tx_dfu.py b/examples/test_tx_dfu.py index 9c7ea0a..41f184c 100644 --- a/examples/test_tx_dfu.py +++ b/examples/test_tx_dfu.py @@ -59,7 +59,7 @@ if user_input == 'y': print("Enter DFU mode") - if interface.txdevice.enter_dfu(module=MODULE_ID): + if interface.txdevice.enter_dfu(module=MODULE_ID, reserved=0x77): print("Successful.") elif user_input == 'n': diff --git a/src/openlifu_sdk/io/LIFUInterface.py b/src/openlifu_sdk/io/LIFUInterface.py index 0d4a914..15a964f 100644 --- a/src/openlifu_sdk/io/LIFUInterface.py +++ b/src/openlifu_sdk/io/LIFUInterface.py @@ -27,33 +27,79 @@ from openlifu_sdk.io.LIFUHVController import HVController from openlifu_sdk.io.LIFUTXDevice import TriggerModeOpts, TxDevice -REF_MAX_SEQUENCE_TIMES = { - "default": [2*60, 5*60, 10*60], # users to use default values - "stress_test": [60*60, 60*60, 60*60] # QA to use stress test values -} - -REF_MAX_DUTY_CYCLES = { - "default": [0.05, 0.1, 0.2, 0.3, 0.4, 0.5], - "stress_test": [0.5, 0.5, 0.5, 0.5, 0.5, 0.5] -} - +# Maximum-voltage lookup tables keyed by hardware/test profile. +# +# Each entry is a dict that fully describes its own anchor points and +# voltage matrix: +# +# - ``duty_cycles`` : list[float], one entry per row of ``voltages``. +# Treated as "max duty cycle for this row" — the +# lookup picks the first row whose limit >= the +# sequence's duty cycle. +# - ``sequence_times`` : list[float] in seconds, one entry per column of +# ``voltages``. Same semantics as ``duty_cycles`` +# but applied to total sequence duration. +# - ``voltages`` : 2D list of ints (rows × cols), giving the max +# voltage allowed for that (duty_cycle, sequence_time) +# cell. +# +# Different profiles may use entirely different anchor points (e.g. ``dvt`` +# is denser in duty cycle than ``evt2``/``evt0``); the lookup uses each +# entry's own anchors, so no global anchor list is required. MAX_VOLTAGE_BY_DUTY_CYCLE_AND_SEQUENCE_TIME = { - "evt2": [ - [45, 45, 45], # 0.05 - [40, 40, 40], # 0.1 - [40, 40, 35], # 0.2 - [40, 35, 30], # 0.3 - [35, 30, 25], # 0.4 - [30, 25, 20] # 0.5 - ], - "evt0": [ - [65, 65, 65], # 0.05 - [65, 65, 50], # 0.1 - [50, 40, 35], # 0.2 - [45, 35, 30], # 0.3 - [35, 30, 25], # 0.4 - [30, 25, 20] # 0.5 - ], + "evt2": { + "duty_cycles": [0.05, 0.1, 0.2, 0.3, 0.4, 0.5], + "sequence_times": [2*60, 5*60, 10*60], + "voltages": [ + [45, 45, 45], # 0.05 + [40, 40, 40], # 0.1 + [40, 40, 35], # 0.2 + [40, 35, 30], # 0.3 + [35, 30, 25], # 0.4 + [30, 25, 20], # 0.5 + ], + }, + "evt0": { + "duty_cycles": [0.05, 0.1, 0.2, 0.3, 0.4, 0.5], + "sequence_times": [2*60, 5*60, 10*60], + "voltages": [ + [65, 65, 65], # 0.05 + [65, 65, 50], # 0.1 + [50, 40, 35], # 0.2 + [45, 35, 30], # 0.3 + [35, 30, 25], # 0.4 + [30, 25, 20], # 0.5 + ], + }, + "dvt": { + "duty_cycles": [0.05, 0.10, 0.15, 0.18, 0.22, 0.28, 0.35, 0.40, 0.45, 0.50], + "sequence_times": [10*60], + "voltages": [ + [65], # 0.05 + [60], # 0.10 + [55], # 0.15 + [50], # 0.18 + [45], # 0.22 + [40], # 0.28 + [35], # 0.35 + [30], # 0.40 + [25], # 0.45 + [20], # 0.50 + ], + }, + # QA / stress-test profiles. Single-cell tables that effectively disable + # the duty-cycle / duration ramp-down: any sequence at or below the listed + # caps is allowed at the listed voltage. + "stress_test_evt0": { + "duty_cycles": [0.5], + "sequence_times": [60*60], + "voltages": [[65]], + }, + "stress_test_evt2": { + "duty_cycles": [0.5], + "sequence_times": [60*60], + "voltages": [[45]], + }, } class LIFUInterfaceStatus(Enum): @@ -186,9 +232,7 @@ def __init__(self, run_async: bool = False, ext_power_supply: bool = False, module_invert: bool | List[bool] = False, - voltage_table_selection: Optional[str] = None, - sequence_time_selection: Optional[str] = None, - duty_cycle_selection: Optional[str] = None) -> None: + voltage_table_selection: Optional[str] = None) -> None: """ Initialize the LIFUInterface with given parameters and store them in the class. @@ -215,8 +259,6 @@ def __init__(self, self.sequence_time = None self.duty_cycles = None self.voltage_table_selection = voltage_table_selection - self.sequence_time_selection = sequence_time_selection - self.duty_cycle_selection = duty_cycle_selection # Create a TXDevice instance as part of the interface self.txdevice = TxDevice(vid=vid, pid=tx_pid, baudrate=baudrate, timeout=timeout, test_mode=TX_test_mode, module_invert=module_invert) @@ -235,35 +277,29 @@ def __init__(self, self.hvcontroller.connect() # Temporary fix for hardware variations between EVT0 and EVT2 - def _resolve_voltage_chart_evt_version(self, voltage_table: str) -> list[list[int]]: + def _resolve_voltage_chart(self, voltage_table: Optional[str]) -> dict: + """Return the voltage-table entry (``duty_cycles`` / ``sequence_times`` / ``voltages``) + for the requested profile. + + If *voltage_table* is ``None``, the profile is inferred from the connected + HV controller's reported version. + """ if voltage_table is None: - evt_version = "evt0" if self.hvcontroller.get_version().startswith("v1.1") else "evt2" + evt_version = "evt0" if self.hvcontroller.get_version().startswith("v1.1") else "dvt" else: evt_version = voltage_table.lower() if evt_version not in MAX_VOLTAGE_BY_DUTY_CYCLE_AND_SEQUENCE_TIME: raise ValueError(f"Invalid voltage_table option '{voltage_table}'. Valid options are: {tuple(MAX_VOLTAGE_BY_DUTY_CYCLE_AND_SEQUENCE_TIME.keys())}") - + print(evt_version) return MAX_VOLTAGE_BY_DUTY_CYCLE_AND_SEQUENCE_TIME[evt_version] - # Restrict sequence time options for users vs QA - def _resolve_max_sequence_time_set(self, sequence_time: str) -> list[int]: - if sequence_time is None: - return REF_MAX_SEQUENCE_TIMES["default"] - else: - sequence_time = sequence_time.lower() - if sequence_time not in REF_MAX_SEQUENCE_TIMES: - raise ValueError(f"Invalid sequence_time option '{sequence_time}'. Valid options are: {tuple(REF_MAX_SEQUENCE_TIMES.keys())}") - return REF_MAX_SEQUENCE_TIMES[sequence_time] - - # Restrict duty cycle options for users vs QA - def _resolve_duty_cycle_set(self, duty_cycle: str) -> list[float]: - if duty_cycle is None: - return REF_MAX_DUTY_CYCLES["default"] - else: - duty_cycle = duty_cycle.lower() - if duty_cycle not in REF_MAX_DUTY_CYCLES: - raise ValueError(f"Invalid duty_cycle option '{duty_cycle}'. Valid options are: {tuple(REF_MAX_DUTY_CYCLES.keys())}") - return REF_MAX_DUTY_CYCLES[duty_cycle] + def _load_voltage_table(self) -> None: + """Populate ``self.voltage_table`` / ``self.duty_cycles`` / ``self.sequence_time`` + from the currently selected profile.""" + entry = self._resolve_voltage_chart(self.voltage_table_selection) + self.duty_cycles = entry["duty_cycles"] + self.sequence_time = entry["sequence_times"] + self.voltage_table = entry["voltages"] async def start_monitoring(self, interval: int = 1) -> None: """Start monitoring for USB device connections.""" @@ -344,10 +380,7 @@ def check_solution(self, solution: Dict) -> None: Raises: LIFUSolutionError: If the solution exceeds any safety limit. """ - - self.voltage_table = self._resolve_voltage_chart_evt_version(self.voltage_table_selection) - self.sequence_time = self._resolve_max_sequence_time_set(self.sequence_time_selection) - self.duty_cycles = self._resolve_duty_cycle_set(self.duty_cycle_selection) + self._load_voltage_table() sequence_duty_cycle = self.get_sequence_duty_cycle(solution) duty_cycles_limits = np.array(self.duty_cycles) if sequence_duty_cycle > duty_cycles_limits.max(): diff --git a/src/openlifu_sdk/io/LIFUTXDevice.py b/src/openlifu_sdk/io/LIFUTXDevice.py index 4774a1f..1f2b5fe 100644 --- a/src/openlifu_sdk/io/LIFUTXDevice.py +++ b/src/openlifu_sdk/io/LIFUTXDevice.py @@ -267,11 +267,55 @@ def set_trigger(self, else: raise ValueError("Invalid trigger mode") - if pulse_train_interval > 0 and (pulse_train_interval < pulse_interval * pulse_count): + if pulse_train_count <= 1: + # Only one train will ever fire, so the inter-train spacing is + # functionally meaningless. Force it to a value derived from + # pulse_interval so the firmware compatibility shim below can + # bump it above the per-pulse period. + pulse_train_interval = pulse_interval + elif pulse_train_interval > 0 and (pulse_train_interval < pulse_interval * pulse_count): raise ValueError("Pulse train interval cannot be less than pulse interval * pulse count") elif pulse_train_interval == 0: pulse_train_interval = pulse_interval * pulse_count + # Firmware <= 2.0.3 compatibility shim. + # + # FW <= 2.0.3 rejects start_trigger with TRIGGER_STATUS_ERROR + # (surfaced as a LIFUDeviceError OW_ERROR NAK with no sub-code) + # whenever: + # TriggerPulseTrainInterval > 0 AND + # TriggerPulseTrainInterval <= triggerPeriodUsec + # where ``triggerPeriodUsec = 1_000_000 / TriggerFrequencyHz`` (the + # per-pulse period) and ``TriggerFrequencyHz`` is parsed as uint32 + # (strtol, base 10) so fractional Hz get truncated. FW 2.0.7+ uses + # strict ``<`` and a pulse_count factor, so it always passes. + # + # When the train interval lands at or within rounding of the + # firmware per-pulse period we lengthen it to + # ``1 / (int(1/pulse_train_interval) - 1)`` -- effectively rounding + # the implied train frequency down by 1 Hz, which lengthens the + # interval just enough to clear the firmware integer period without + # changing pulse_interval (the user-meaningful sonication frequency). + train_us = int(round(pulse_train_interval * 1_000_000)) + if train_us > 0 and pulse_interval > 0: + freq_int = int(1.0 / pulse_interval) + period_us = 1_000_000 // freq_int if freq_int > 0 else 0 + if train_us <= period_us: + train_freq_int = int(1.0 / pulse_train_interval) + if train_freq_int > 1: + new_train_s = 1.0 / (train_freq_int - 1) + else: + new_train_s = pulse_train_interval + 1e-6 + logger.debug( + "FW <=2.0.3 compat: bumping pulse_train_interval from " + "%s s (%s us) to %s s (%s us) so it exceeds the firmware " + "per-pulse period (%s us).", + pulse_train_interval, train_us, + new_train_s, int(round(new_train_s * 1_000_000)), + period_us, + ) + pulse_train_interval = new_train_s + logger.info(f"Setting trigger with parameters: " f"pulse_interval={pulse_interval}, " f"pulse_count={pulse_count}, " diff --git a/src/openlifu_sdk/io/component.py b/src/openlifu_sdk/io/component.py index 8426b17..cf7306b 100644 --- a/src/openlifu_sdk/io/component.py +++ b/src/openlifu_sdk/io/component.py @@ -185,8 +185,65 @@ def send_checked(self, command: int, addr: int = 0, reserved: int = 0, continue raise last_timeout_exc if r.packet_type == OW_ERROR: + # Surface whatever diagnostic detail the device packed + # into the OW_ERROR reply so the failure isn't reduced + # to a bare "returned device error". The firmware uses + # the ``reserved`` byte as a sub-error code and may + # also stash a payload (often an ASCII reason string, + # sometimes packed struct bytes). Include both forms + # so the operator-facing popup carries enough context + # to triage the failure without a debug build. + # + # Attribute fetches are tolerant of mocks / partial + # packets used in tests -- only ints with sane values + # are formatted as hex, everything else is dropped. + def _as_int(value): + if isinstance(value, bool): + return None + return value if isinstance(value, int) else None + + def _as_bytes(value): + if value is None: + return b"" + try: + return bytes(value) + except TypeError: + return b"" + + cmd_byte = _as_int(getattr(r, "command", None)) + addr_byte = _as_int(getattr(r, "addr", None)) + device_err_code = _as_int(getattr(r, "reserved", None)) + payload = _as_bytes(getattr(r, "data", None)) + + detail_parts: list[str] = [] + if cmd_byte is not None: + detail_parts.append(f"cmd=0x{cmd_byte:02X}") + if addr_byte is not None: + detail_parts.append(f"addr=0x{addr_byte:02X}") + if device_err_code is not None: + detail_parts.append(f"device_err=0x{device_err_code:02X}") + if payload: + payload_hex = payload.hex(" ") + detail_parts.append(f"payload={payload_hex}") + # Best-effort decode: most firmware error payloads + # are short ASCII reason strings. Fall through + # silently if it isn't decodable as printable text. + try: + text = payload.rstrip(b"\x00").decode("ascii") + except UnicodeDecodeError: + text = "" + if text and all(c.isprintable() or c in "\r\n\t" for c in text): + detail_parts.append(f'message="{text.strip()}"') + + detail = " ".join(detail_parts) + msg = f"{self._uart.desc}: {label} returned device error" + if detail: + msg = f"{msg} ({detail})" raise LIFUDeviceError( - f"{self._uart.desc}: {label} returned device error" + msg, + packet=r, + device_error_code=device_err_code, + device_error_data=payload or None, ) if attempt > 1: # Make it visible WHEN slow responses are recovering -- @@ -302,13 +359,13 @@ def soft_reset(self, module: int = 0) -> bool: self.send_checked(OW_CMD_RESET, addr=module, op="soft_reset") return True - def enter_dfu(self, module: int = 0) -> bool: + def enter_dfu(self, module: int = 0, reserved: int = 0x00) -> bool: """Reboot the device into DFU mode. Raises: LIFUNotConnectedError, LIFUCommunicationError, LIFUDeviceError. """ - self.send_checked(OW_CMD_DFU, addr=module, op="enter_dfu") + self.send_checked(OW_CMD_DFU, addr=module, op="enter_dfu", reserved=reserved) return True # ------------------------------------------------------------------ diff --git a/src/openlifu_sdk/io/exceptions.py b/src/openlifu_sdk/io/exceptions.py index 96b097d..dca84ea 100644 --- a/src/openlifu_sdk/io/exceptions.py +++ b/src/openlifu_sdk/io/exceptions.py @@ -77,10 +77,35 @@ class LIFUCommunicationError(LIFUError): class LIFUDeviceError(LIFUError): - """Device firmware returned an ``OW_ERROR`` packet.""" + """Device firmware returned an ``OW_ERROR`` packet. + + The optional ``packet`` attribute holds the raw OW_ERROR + :class:`OWUartPacket` reply when the exception originates from + :meth:`OWComponent.send_checked`. The ``device_error_code`` field + is the device-side error byte (carried in the OW_ERROR packet's + ``reserved`` slot) and ``device_error_data`` is any extra payload + bytes -- both ``None`` if the exception was constructed without a + packet. + """ default_code = LIFU_ERR_DEVICE_NAK + def __init__( + self, + message: str = "", + *, + code: int | None = None, + packet: object | None = None, + device_error_code: int | None = None, + device_error_data: bytes | None = None, + ): + self.packet = packet + self.device_error_code = device_error_code + self.device_error_data = ( + bytes(device_error_data) if device_error_data is not None else None + ) + super().__init__(message, code=code) + class LIFUProtocolError(LIFUError): """Response from the device was malformed or had an unexpected length.""" diff --git a/src/openlifu_sdk/ui/simulated_interface.py b/src/openlifu_sdk/ui/simulated_interface.py index 0848ef7..3e330ad 100644 --- a/src/openlifu_sdk/ui/simulated_interface.py +++ b/src/openlifu_sdk/ui/simulated_interface.py @@ -743,8 +743,6 @@ class SimulatedLIFUInterface(QObject): def __init__(self, num_modules: int = 1, transducer=None, voltage_table_selection: Optional[str] = None, - sequence_time_selection: Optional[str] = None, - duty_cycle_selection: Optional[str] = None, **_unused): # When a transducer (array) is supplied, derive num_modules from it # so the TX device is built with the right module count up front. @@ -758,8 +756,6 @@ def __init__(self, num_modules: int = 1, self.status = LIFUInterfaceStatus.STATUS_SYS_OFF self._engine: Optional[_SimulatedRunEngine] = None self.voltage_table_selection = voltage_table_selection - self.sequence_time_selection = sequence_time_selection - self.duty_cycle_selection = duty_cycle_selection self._last_solution_voltage = 0.0 self._last_trigger_mode = "sequence" if transducer is not None and getattr(transducer, "modules", None) is not None: diff --git a/unit-test/test_tx_device.py b/unit-test/test_tx_device.py index 4c17a15..791841d 100644 --- a/unit-test/test_tx_device.py +++ b/unit-test/test_tx_device.py @@ -262,6 +262,79 @@ def test_23_set_trigger_invalid_mode_raises(self): with self.assertRaises(ValueError): self.tx.set_trigger(pulse_interval=0.1, trigger_mode="invalid") + def test_23b_set_trigger_single_train_lengthens_train_interval(self): + """set_trigger() lengthens TriggerPulseTrainInterval when + pulse_train_count<=1 so it clears the firmware per-pulse period. + + For single-train operation the inter-train spacing is meaningless, + but firmware <= 2.0.3 still NAKs start_trigger when + TriggerPulseTrainInterval > 0 AND <= triggerPeriodUsec, and that + firmware parses TriggerFrequencyHz as uint32 (strtol base 10) so + fractional Hz get truncated. To make ``train_us > period_us`` + without changing the user-visible sonication frequency, the SDK + copies pulse_interval into pulse_train_interval and then lengthens + the train interval to ``1 / (int(1/burst) - 1)`` (equivalent to + rounding the implied train frequency down by 1 Hz). + """ + response = {"TriggerFrequencyHz": 50.0, "TriggerMode": TRIGGER_MODE_SEQUENCE} + self.uart.send_packet.return_value = _make_packet(json.dumps(response).encode()) + self.tx.set_trigger(pulse_interval=0.02, pulse_count=1, pulse_width=20, + trigger_mode="continuous") + sent_json = json.loads(self.uart.send_packet.call_args.kwargs["data"].decode()) + # pulse_interval (frequency) is preserved. + self.assertEqual(int(sent_json["TriggerFrequencyHz"]), 50) + # train_us = int(1/49 * 1e6) = 20408 us (> period 20000 us) + self.assertEqual(int(sent_json["TriggerPulseTrainInterval"]), 20408) + + # Same outcome when caller explicitly passes a nonzero interval. + self.uart.send_packet.reset_mock() + self.uart.send_packet.return_value = _make_packet(json.dumps(response).encode()) + self.tx.set_trigger(pulse_interval=0.02, pulse_count=1, pulse_width=20, + pulse_train_interval=1.0, pulse_train_count=1, + trigger_mode="continuous") + sent_json = json.loads(self.uart.send_packet.call_args.kwargs["data"].decode()) + # train_count<=1 ignores the explicit interval and uses pulse_interval instead. + self.assertEqual(int(sent_json["TriggerFrequencyHz"]), 50) + self.assertEqual(int(sent_json["TriggerPulseTrainInterval"]), 20408) + + def test_23c_set_trigger_multi_train_lengthens_train_interval(self): + """set_trigger() lengthens TriggerPulseTrainInterval for the + multi-train, pulse_count==1 case where the auto-filled or + explicitly-supplied train interval lands exactly on the period. + """ + response = {"TriggerFrequencyHz": 50.0, "TriggerMode": TRIGGER_MODE_SEQUENCE} + self.uart.send_packet.return_value = _make_packet(json.dumps(response).encode()) + self.tx.set_trigger(pulse_interval=0.02, pulse_count=1, pulse_width=20, + pulse_train_interval=0.0, pulse_train_count=3, + trigger_mode="sequence") + sent_json = json.loads(self.uart.send_packet.call_args.kwargs["data"].decode()) + self.assertEqual(int(sent_json["TriggerFrequencyHz"]), 50) + self.assertEqual(int(sent_json["TriggerPulseTrainInterval"]), 20408) + + # Explicit interval equal to pulse_interval * pulse_count: same fix. + self.uart.send_packet.reset_mock() + self.uart.send_packet.return_value = _make_packet(json.dumps(response).encode()) + self.tx.set_trigger(pulse_interval=0.02, pulse_count=1, pulse_width=20, + pulse_train_interval=0.02, pulse_train_count=3, + trigger_mode="sequence") + sent_json = json.loads(self.uart.send_packet.call_args.kwargs["data"].decode()) + self.assertEqual(int(sent_json["TriggerFrequencyHz"]), 50) + self.assertEqual(int(sent_json["TriggerPulseTrainInterval"]), 20408) + + def test_23d_set_trigger_multi_train_pulse_count_gt_1_no_bump(self): + """set_trigger() leaves both fields alone when pulse_count>1 and the + auto-fill already exceeds the per-pulse period. + """ + response = {"TriggerFrequencyHz": 50.0, "TriggerMode": TRIGGER_MODE_SEQUENCE} + self.uart.send_packet.return_value = _make_packet(json.dumps(response).encode()) + self.tx.set_trigger(pulse_interval=0.02, pulse_count=4, pulse_width=20, + pulse_train_interval=0.0, pulse_train_count=3, + trigger_mode="sequence") + sent_json = json.loads(self.uart.send_packet.call_args.kwargs["data"].decode()) + # pulse_interval (0.02 s) * pulse_count (4) = 0.08 s -> 80000 us + self.assertEqual(sent_json["TriggerPulseTrainInterval"], 80000) + self.assertEqual(int(sent_json["TriggerFrequencyHz"]), 50) + # --- start / stop trigger ----------------------------------------------- def test_24_start_trigger_success(self): From d1316c3f28507ffc83d4c81898b3fd9332068767 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Tue, 16 Jun 2026 18:41:09 -0700 Subject: [PATCH 02/68] update dfu script --- examples/test_tx_dfu.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/examples/test_tx_dfu.py b/examples/test_tx_dfu.py index 41f184c..3336437 100644 --- a/examples/test_tx_dfu.py +++ b/examples/test_tx_dfu.py @@ -54,12 +54,39 @@ print(f"Version: {version}") +def _parse_fw_version(ver: str) -> tuple[int, int, int] | None: + """Parse a 'vX.Y.Z' (or 'X.Y.Z') firmware string into a tuple, or None.""" + if not ver: + return None + parts = ver.lstrip("vV").split(".") + if len(parts) < 3: + return None + try: + return (int(parts[0]), int(parts[1]), int(parts[2])) + except ValueError: + return None + + +# The reserved=0x77 flag triggers the legacy DFU pass-through path that +# firmware <= 2.0.3 requires to enter DFU mode. Firmware 2.0.4+ ignores it +# (and in some intermediate builds, sending it causes a NAK), so only set +# it when we're talking to a known-old firmware. If the version is +# unparseable we err on the side of NOT sending it (assume modern firmware). +_LEGACY_DFU_MAX = (2, 0, 3) +_parsed = _parse_fw_version(version) +_dfu_reserved = 0x77 if (_parsed is not None and _parsed <= _LEGACY_DFU_MAX) else 0x00 +if _dfu_reserved: + print(f"Firmware {version} <= v2.0.3 detected; using legacy reserved=0x77 DFU flag.") +else: + print(f"Firmware {version} > v2.0.3 (or unparseable); using reserved=0x00.") + + # Ask the user for confirmation user_input = input("Do you want to Enter DFU Mode? (y/n): ").strip().lower() if user_input == 'y': print("Enter DFU mode") - if interface.txdevice.enter_dfu(module=MODULE_ID, reserved=0x77): + if interface.txdevice.enter_dfu(module=MODULE_ID, reserved=_dfu_reserved): print("Successful.") elif user_input == 'n': From 06368ea9a30a033c75babac8b177f62ebdb3776c Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Tue, 16 Jun 2026 18:50:17 -0700 Subject: [PATCH 03/68] remove print --- src/openlifu_sdk/io/LIFUInterface.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/openlifu_sdk/io/LIFUInterface.py b/src/openlifu_sdk/io/LIFUInterface.py index 15a964f..2f658a7 100644 --- a/src/openlifu_sdk/io/LIFUInterface.py +++ b/src/openlifu_sdk/io/LIFUInterface.py @@ -290,7 +290,6 @@ def _resolve_voltage_chart(self, voltage_table: Optional[str]) -> dict: evt_version = voltage_table.lower() if evt_version not in MAX_VOLTAGE_BY_DUTY_CYCLE_AND_SEQUENCE_TIME: raise ValueError(f"Invalid voltage_table option '{voltage_table}'. Valid options are: {tuple(MAX_VOLTAGE_BY_DUTY_CYCLE_AND_SEQUENCE_TIME.keys())}") - print(evt_version) return MAX_VOLTAGE_BY_DUTY_CYCLE_AND_SEQUENCE_TIME[evt_version] def _load_voltage_table(self) -> None: From 94d3897df41210be1b3ab87498a3a9e8e0c48a95 Mon Sep 17 00:00:00 2001 From: Peter Hollender Date: Tue, 16 Jun 2026 18:36:27 -0700 Subject: [PATCH 04/68] Add 2.0.3 triggering compatibility --- examples/test_tx_dfu.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/test_tx_dfu.py b/examples/test_tx_dfu.py index 3336437..582a24e 100644 --- a/examples/test_tx_dfu.py +++ b/examples/test_tx_dfu.py @@ -8,6 +8,10 @@ # set PYTHONPATH=%cd%\src;%PYTHONPATH% # python examples\test_tx_dfu.py MODULE_ID = 0 +DFU_RESERVED_LEGACY = 0x77 +DFU_RESERVED = 0x00 +LEGACY_VERSION_MAX = (2, 0, 3) + print("Starting LIFU Test Script...") interface = LIFUInterface() @@ -54,7 +58,7 @@ print(f"Version: {version}") -def _parse_fw_version(ver: str) -> tuple[int, int, int] | None: +def parse_fw_version(ver: str) -> tuple[int, int, int] | None: """Parse a 'vX.Y.Z' (or 'X.Y.Z') firmware string into a tuple, or None.""" if not ver: return None @@ -72,10 +76,9 @@ def _parse_fw_version(ver: str) -> tuple[int, int, int] | None: # (and in some intermediate builds, sending it causes a NAK), so only set # it when we're talking to a known-old firmware. If the version is # unparseable we err on the side of NOT sending it (assume modern firmware). -_LEGACY_DFU_MAX = (2, 0, 3) -_parsed = _parse_fw_version(version) -_dfu_reserved = 0x77 if (_parsed is not None and _parsed <= _LEGACY_DFU_MAX) else 0x00 -if _dfu_reserved: +fw_version = parse_fw_version(version) +reserved = DFU_RESERVED_LEGACY if (fw_version is not None and fw_version <= LEGACY_VERSION_MAX) else DFU_RESERVED +if reserved == DFU_RESERVED_LEGACY: print(f"Firmware {version} <= v2.0.3 detected; using legacy reserved=0x77 DFU flag.") else: print(f"Firmware {version} > v2.0.3 (or unparseable); using reserved=0x00.") @@ -86,7 +89,7 @@ def _parse_fw_version(ver: str) -> tuple[int, int, int] | None: if user_input == 'y': print("Enter DFU mode") - if interface.txdevice.enter_dfu(module=MODULE_ID, reserved=_dfu_reserved): + if interface.txdevice.enter_dfu(module=MODULE_ID, reserved=reserved): print("Successful.") elif user_input == 'n': From a77ae4e938f4ac032133268ae08b3fb7e09f66a7 Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Sat, 11 Jul 2026 23:31:57 -0400 Subject: [PATCH 05/68] udpate getversion --- examples/test_tx_getversion.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/examples/test_tx_getversion.py b/examples/test_tx_getversion.py index 23d8416..79e1a5b 100644 --- a/examples/test_tx_getversion.py +++ b/examples/test_tx_getversion.py @@ -49,6 +49,16 @@ print("❌ failed to communicate with transmit module") sys.exit(1) -print("Get Version") -version = interface.txdevice.get_version() -print(f"Version: {version}") +module_idx = 0 # Assuming you want to get the version for module index 0 +module_count = interface.txdevice.get_tx_module_count() +print(f"TX Module Count: {module_count}") + +for module_idx in range(module_count): + print(f"Getting version for module index: {module_idx}") + version = interface.txdevice.get_version(module=module_idx) + print(f"Version for module {module_idx}: {version}") + + hardware_id = interface.txdevice.get_hardware_id(module=module_idx) + print(f"Hardware ID for module {module_idx}: {hardware_id}") + + From bfcfea804ee14dd40d47a8a7b962f672a0c68bb2 Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Sun, 12 Jul 2026 10:19:51 -0400 Subject: [PATCH 06/68] check module mode dfu or app --- src/openlifu_sdk/io/LIFUConfig.py | 9 +++ src/openlifu_sdk/io/LIFUTXDevice.py | 90 ++++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/src/openlifu_sdk/io/LIFUConfig.py b/src/openlifu_sdk/io/LIFUConfig.py index 36ba596..00a0412 100644 --- a/src/openlifu_sdk/io/LIFUConfig.py +++ b/src/openlifu_sdk/io/LIFUConfig.py @@ -40,11 +40,17 @@ OW_CMD_GET_AMBIENT = 0x07 OW_CMD_ASYNC = 0x09 OW_CMD_USR_CFG = 0x0A +OW_CMD_CLEAR_CONFIG = 0x0B # Phase 2: chain broadcast to drop stale I2C addresses OW_CMD_DISCOVERY = 0x0C OW_CMD_DFU = 0x0D OW_CMD_NOP = 0x0E OW_CMD_RESET = 0x0F +# Node operating mode reported by OW_CTRL_GET_MODULE_MODE / discovery (Phase 2) +NODE_MODE_UNKNOWN = 0x00 +NODE_MODE_APP = 0x01 # module is running application firmware +NODE_MODE_BOOTLOADER = 0x02 # module is in the secure bootloader (I2C DFU) + # Controller Commands OW_CTRL_SET_SWTRIG = 0x13 OW_CTRL_GET_SWTRIG = 0x14 @@ -52,6 +58,8 @@ OW_CTRL_STOP_SWTRIG = 0x16 OW_CTRL_STATUS_SWTRIG = 0x17 OW_CTRL_GET_MODULE_COUNT = 0x1A +OW_CTRL_GET_MODULE_MODE = 0x1B # Phase 2: per-module NodeMode (app vs bootloader) +OW_CTRL_ENUMERATE = 0x1C # Phase 2: re-run clear-config + discovery walk # TX7332 Commands OW_TX7332_STATUS = 0x20 @@ -107,6 +115,7 @@ CONTROLLER_COMMANDS = { OW_CTRL_SET_SWTRIG, OW_CTRL_GET_SWTRIG, OW_CTRL_START_SWTRIG, OW_CTRL_STOP_SWTRIG, OW_CTRL_STATUS_SWTRIG, OW_CTRL_GET_MODULE_COUNT, + OW_CTRL_GET_MODULE_MODE, OW_CTRL_ENUMERATE, } POWER_COMMANDS = { diff --git a/src/openlifu_sdk/io/LIFUTXDevice.py b/src/openlifu_sdk/io/LIFUTXDevice.py index 1f2b5fe..da2756c 100644 --- a/src/openlifu_sdk/io/LIFUTXDevice.py +++ b/src/openlifu_sdk/io/LIFUTXDevice.py @@ -120,6 +120,11 @@ OW_CMD_ECHO, OW_CMD_GET_AMBIENT, OW_CTRL_GET_MODULE_COUNT, + OW_CTRL_GET_MODULE_MODE, + OW_CTRL_ENUMERATE, + NODE_MODE_APP, + NODE_MODE_BOOTLOADER, + NODE_MODE_UNKNOWN, OW_CMD_GET_TEMP, OW_CMD_HWID, OW_CMD_PING, @@ -718,7 +723,90 @@ def get_module_count(self) -> int: count = r.data[0] logger.debug("Detected %d module(s)", count) return count - + + def enumerate_modules(self) -> int: + """Re-run the master's robust enumeration (Phase 2). + + The master broadcasts OW_CMD_CLEAR_CONFIG down the one-wire chain (so every + node — application or bootloader — drops any stale I2C address), waits for + the chain to re-ready, then re-runs the discovery walk assigning fresh + addresses 0x20, 0x21, .... Each node reports whether it is running the + application or is stuck in the bootloader. + + Use this after forcing one or more slaves into DFU so they pick up unique + addresses instead of all colliding at 0x72. + + Returns: + int: the new module count (including the master). + """ + # The master runs the discovery walk synchronously before replying; each hop + # can incur one-wire timeouts (up to ~0.5 s). Allow generous headroom so a + # slow-but-successful walk is not reported as a transport timeout. + # + # retries=0 is important: a retry would send a SECOND enumerate while the + # master is still processing the first (the walk is long), overlapping two + # re-enumerations and corrupting the master's state. One shot only. + r = self.send_checked(packet_type=OW_CONTROLLER, command=OW_CTRL_ENUMERATE, + addr=0, op="enumerate_modules", timeout=30.0, retries=0) + if not r.data or len(r.data) < 1: + raise LIFUProtocolError( + f"TX: enumerate_modules payload length {r.data_len} < 1", + code=LIFU_ERR_BAD_PAYLOAD_LENGTH, + ) + count = r.data[0] + logger.debug("Re-enumeration detected %d module(s)", count) + return count + + def get_module_mode(self, module: int) -> int: + """Return a module's operating mode: NODE_MODE_APP or NODE_MODE_BOOTLOADER. + + The mode is recorded by the master during discovery (Phase 2). Module 0 + (the master) is always NODE_MODE_APP. A bootloader-mode module listens for + I2C DFU at its assigned address (see :meth:`get_module_i2c_addr`). + + Args: + module: module index (0 = master, 1.. = slaves). + + Returns: + int: one of NODE_MODE_APP / NODE_MODE_BOOTLOADER / NODE_MODE_UNKNOWN. + """ + r = self.send_checked(packet_type=OW_CONTROLLER, command=OW_CTRL_GET_MODULE_MODE, + addr=module, op="get_module_mode") + if not r.data or len(r.data) < 1: + raise LIFUProtocolError( + f"TX: get_module_mode payload length {r.data_len} < 1", + code=LIFU_ERR_BAD_PAYLOAD_LENGTH, + ) + return r.data[0] + + def get_module_i2c_addr(self, module: int) -> int: + """Return the assigned 7-bit I2C address for a slave module index. + + Slaves are enumerated sequentially from 0x20 (module 1 -> 0x20, module 2 -> + 0x21, ...). This mirrors BASE_I2C_ADDRESS in the firmware. Module 0 (the + master) has no slave-bus address; 0 is returned. + """ + if module <= 0: + return 0 + return 0x20 + (module - 1) + + def scan_module_modes(self) -> list[dict]: + """Return a per-module summary of index, mode and I2C address. + + Convenience wrapper around get_module_count + get_module_mode, e.g.:: + + [{"module": 0, "mode": NODE_MODE_APP, "i2c_addr": 0x00}, + {"module": 1, "mode": NODE_MODE_BOOTLOADER, "i2c_addr": 0x20}, ...] + """ + out = [] + for m in range(self.get_module_count()): + try: + mode = self.get_module_mode(m) + except LIFUError: + mode = NODE_MODE_UNKNOWN + out.append({"module": m, "mode": mode, "i2c_addr": self.get_module_i2c_addr(m)}) + return out + def update_firmware(self, module: int, package_file: str, vid: int = 0x0483, pid: int = 0xDF11, libusb_dll: str | None = None, From c30e332cf20ae919b893a6e5ec7c0792dd94c742 Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Tue, 14 Jul 2026 15:03:57 -0400 Subject: [PATCH 07/68] stop assuming i2c address --- examples/test_tx_i2c_update.py | 408 ++++++++++++++++----------------- 1 file changed, 198 insertions(+), 210 deletions(-) diff --git a/examples/test_tx_i2c_update.py b/examples/test_tx_i2c_update.py index 94813dd..287256d 100644 --- a/examples/test_tx_i2c_update.py +++ b/examples/test_tx_i2c_update.py @@ -1,210 +1,198 @@ -"""LIFU Transmitter I2C Firmware Update — DFU-already-active variant - -Use this script when the slave module is already sitting in DFU bootloader -mode (e.g. it failed to boot its application and fell back to the BL, or you -entered DFU mode manually) and the normal test_tx_fw_update.py flow cannot -connect to the live application to request DFU entry. - -The script: - 1. Connects to the master module via UART (USB VCP). - 2. Pings the slave I2C DFU bootloader at *i2c_addr* (default 0x72) via the - master's OW_I2C_PASSTHRU passthrough to confirm it is responsive. - 3. Programs the signed firmware package. - 4. Optionally pings the slave after reset to report the new version. - -Usage ------ - set PYTHONPATH=%cd%\\src;%PYTHONPATH% - python examples\\test_tx_i2c_update.py [options] - -Examples --------- - # Defaults (VID=0x0483, PID=0x57AF, slave addr=0x72) - python examples\\test_tx_i2c_update.py build\\DebugBL\\lifu-transmitter-fw.bin.signed.bin - - # Custom slave address - python examples\\test_tx_i2c_update.py firmware.bin.signed.bin --i2c-addr 0x73 -""" - -from __future__ import annotations - -import argparse -import sys -import time - -from openlifu_sdk.io.LIFUDFU import I2C_DFU_SLAVE_ADDR, LIFUDFUManager -from openlifu_sdk.io.LIFUUart import LIFUUart - - -# --------------------------------------------------------------------------- -# Progress display helper -# --------------------------------------------------------------------------- - -def _progress(written: int, total: int, label: str) -> None: - pct = 100 * written // total - filled = pct // 5 - bar = "#" * filled + "-" * (20 - filled) - print(f"\r {label}: [{bar}] {pct:3d}% ({written}/{total} B)", - end="", flush=True) - if written >= total: - print() - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def main() -> None: - p = argparse.ArgumentParser( - description="LIFU I2C DFU firmware update (slave already in DFU mode)", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=__doc__, - ) - p.add_argument( - "package_file", - help="Path to signed firmware package (.bin.signed.bin)" - ) - p.add_argument( - "--i2c-addr", type=lambda x: int(x, 0), - default=I2C_DFU_SLAVE_ADDR, metavar="ADDR", - help=f"I2C slave address of DFU bootloader (default: 0x{I2C_DFU_SLAVE_ADDR:02X})" - ) - p.add_argument( - "--vid", type=lambda x: int(x, 0), default=0x0483, - help="USB VID of the master TX module VCP (default: 0x0483)" - ) - p.add_argument( - "--pid", type=lambda x: int(x, 0), default=0x57AF, - help="USB PID of the master TX module VCP (default: 0x57AF)" - ) - p.add_argument( - "--baudrate", type=int, default=921600, - help="UART baud rate (default: 921600)" - ) - p.add_argument( - "--post-wait", type=float, default=3.0, metavar="SEC", - help="Seconds to wait after reset before reading new version (default: 3.0)" - ) - p.add_argument( - "--yes", "-y", action="store_true", - help="Skip the confirmation prompt" - ) - args = p.parse_args() - - print("=" * 60) - print(" LIFU I2C DFU Firmware Update (slave already in DFU mode)") - print("=" * 60) - print(f" Package file : {args.package_file}") - print(f" Slave I2C addr: 0x{args.i2c_addr:02X}") - print(f" Master VCP : VID=0x{args.vid:04X}, PID=0x{args.pid:04X}") - print() - - # ------------------------------------------------------------------ - # Connect to master module UART - # ------------------------------------------------------------------ - print("Connecting to master module UART...") - uart = LIFUUart(vid=args.vid, pid=args.pid, baudrate=args.baudrate, - timeout=10, desc="TX") - uart.port = uart.list_vcp_with_vid_pid() - if uart.port is None: - print(f"ERROR: No USB VCP found with VID=0x{args.vid:04X}, PID=0x{args.pid:04X}.") - sys.exit(1) - uart.connect() - - if not uart.is_connected(): - print("ERROR: Could not connect to master module UART.") - sys.exit(1) - print(f" Connected on {uart.port}.") - - mgr = LIFUDFUManager(uart=uart) - - # ------------------------------------------------------------------ - # Ping the slave DFU bootloader - # ------------------------------------------------------------------ - print(f"\nPinging slave DFU bootloader at 0x{args.i2c_addr:02X}...") - try: - bl_version = mgr.get_bootloader_version_i2c(i2c_addr=args.i2c_addr) - print(f" Bootloader version: {bl_version}") - except Exception as e: - print(f"ERROR: Slave DFU bootloader at 0x{args.i2c_addr:02X} did not respond: {e}") - print(" Make sure the slave module is powered and in DFU bootloader mode.") - uart.disconnect() - sys.exit(1) - - # ------------------------------------------------------------------ - # Confirmation - # ------------------------------------------------------------------ - print() - if not args.yes: - answer = input( - f"Proceed with firmware update on slave 0x{args.i2c_addr:02X}? (y/n): " - ).strip().lower() - if answer != "y": - print("Aborted by user.") - uart.disconnect() - sys.exit(0) - - # ------------------------------------------------------------------ - # Show package layout before programming - # ------------------------------------------------------------------ - from openlifu_sdk.io.LIFUDFU import STM32I2CDFUviaMaster, parse_signed_package - with open(args.package_file, "rb") as _f: - _pkg = parse_signed_package(_f.read()) - print(f" Package layout:") - print(f" fw : {len(_pkg['fw']):6d} B @ 0x{_pkg['fw_address']:08X}") - print(f" meta: {len(_pkg['meta']):6d} B @ 0x{_pkg['meta_address']:08X} (written by bootloader at manifest)") - - # ------------------------------------------------------------------ - # Program - # ------------------------------------------------------------------ - print(f"\nProgramming slave 0x{args.i2c_addr:02X}...") - try: - mgr.program_i2c( - package_file=args.package_file, - i2c_addr=args.i2c_addr, - progress_callback=_progress, - ) - except RuntimeError as e: - print(f"\nERROR: Programming failed — {e}") - uart.disconnect() - sys.exit(1) - except Exception as e: - print(f"\nERROR: Unexpected error — {e}") - uart.disconnect() - sys.exit(1) - - print("\nProgramming complete. Resetting slave...") - try: - dfu = STM32I2CDFUviaMaster(uart=uart, i2c_addr=args.i2c_addr) - dfu.reset() - except Exception as e: - print(f" WARNING: reset command failed ({e}) — slave may self-reset after manifest.") - - # ------------------------------------------------------------------ - # Post-update version check - # ------------------------------------------------------------------ - print(f"Waiting {args.post_wait:.0f} s for slave to boot application...") - time.sleep(args.post_wait) - - try: - from openlifu_sdk.io.LIFUConfig import OW_CONTROLLER - from openlifu_sdk.io.LIFUConfig import OW_CMD_VERSION - - # Module index 1 is the first slave; send version request via UART OW - r = uart.send_packet(id=None, packetType=OW_CONTROLLER, - command=OW_CMD_VERSION, addr=1) - if r is not None and r.data: - new_version = bytes(r.data).rstrip(b"\x00").decode("ascii", errors="replace") - print(f" New firmware version: {new_version}") - else: - print(" WARNING: version read returned no data — " - "slave may still be booting or module index differs.") - except Exception as e: - print(f" WARNING: post-update version check failed ({e})") - - uart.disconnect() - print("\nDone.") - - -if __name__ == "__main__": - main() +"""LIFU Transmitter I2C Firmware Update — DFU-already-active variant + +Use this script when the slave module is already sitting in DFU bootloader +mode (e.g. it has no application yet, it failed to boot its application and +fell back to the BL, or you entered DFU mode manually) and the normal +firmware-update flow cannot connect to the live application to request DFU +entry. + +The SECURE bootloader (open-lifu-transmitter-bl) consumes the RAW signed +image produced by sign_firmware.py — [320B 'SFU1' header][0xFF pad][encrypted +firmware] — written whole to the slot base 0x08010000. It does NOT use the +legacy PGK1 package format (parse_signed_package / program_i2c), whose +metadata-page writes the secure BL rejects with BAD_ADDR. + +The script: + 1. Connects to the master module via LIFUInterface (USB VCP). + 2. Pings the slave I2C DFU bootloader at *i2c_addr* (default 0x72) via the + master's OW_I2C_PASSTHRU passthrough to confirm it is responsive. + 3. Programs the raw signed image: mass erase -> write @0x08010000 -> + manifest -> reset. + 4. Reads back the new application version through the master. + +Usage +----- + set PYTHONPATH=%cd%\\src;%PYTHONPATH% + python examples\\test_tx_i2c_update.py [options] + +Examples +-------- + # Defaults (slave addr=0x72, module index 1) + python examples\\test_tx_i2c_update.py openlifu-transmitter-fw-signed.bin + + # Custom slave address + python examples\\test_tx_i2c_update.py openlifu-transmitter-fw-signed.bin --i2c-addr 0x73 + +See also: open-lifu-transmitter-bl/test/program_slave_i2c.py (same flow, with +an optional enter-DFU step for a slave whose application is still running). +""" + +from __future__ import annotations + +import argparse +import sys +import time + +from openlifu_sdk.io.LIFUDFU import I2C_DFU_SLAVE_ADDR, STM32I2CDFUviaMaster +from openlifu_sdk.io.LIFUInterface import LIFUInterface + +SLOT_BASE = 0x08010000 # raw signed image is written here whole + + +# --------------------------------------------------------------------------- +# Progress display helper +# --------------------------------------------------------------------------- + +def _progress(written: int, total: int, label: str = "write") -> None: + pct = 100 * written // total if total else 100 + filled = pct // 5 + bar = "#" * filled + "-" * (20 - filled) + print(f"\r {label}: [{bar}] {pct:3d}% ({written}/{total} B)", + end="", flush=True) + if written >= total: + print() + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + p = argparse.ArgumentParser( + description="LIFU I2C DFU firmware update (slave already in DFU mode)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + p.add_argument( + "signed_image", + help="Raw signed image from sign_firmware.py " + "(e.g. openlifu-transmitter-fw-signed.bin)" + ) + p.add_argument( + "--module", type=int, default=1, metavar="IDX", + help="Module index of the slave (default: 1)" + ) + p.add_argument( + "--i2c-addr", type=lambda x: int(x, 0), + default=I2C_DFU_SLAVE_ADDR, metavar="ADDR", + help=f"I2C slave address of DFU bootloader (default: 0x{I2C_DFU_SLAVE_ADDR:02X})" + ) + p.add_argument( + "--post-wait", type=float, default=6.0, metavar="SEC", + help="Seconds to wait after reset before reading new version (default: 6.0)" + ) + p.add_argument( + "--yes", "-y", action="store_true", + help="Skip the confirmation prompt" + ) + args = p.parse_args() + + print("=" * 60) + print(" LIFU I2C DFU Firmware Update (slave already in DFU mode)") + print("=" * 60) + print(f" Signed image : {args.signed_image}") + print(f" Module index : {args.module}") + print(f" Slave I2C addr: 0x{args.i2c_addr:02X}") + print() + + # ------------------------------------------------------------------ + # Sanity-check the image before anything touches hardware + # ------------------------------------------------------------------ + with open(args.signed_image, "rb") as f: + raw = f.read() + if raw[0:4] != b"SFU1": + print(f"ERROR: {args.signed_image} does not start with 'SFU1' — " + f"expected a raw signed image from sign_firmware.py.") + sys.exit(1) + print(f" Image: {len(raw)} bytes -> slave slot 0x{SLOT_BASE:08X}") + print() + + # ------------------------------------------------------------------ + # Connect to the master module + # ------------------------------------------------------------------ + print("Connecting to LIFU interface...") + interface = LIFUInterface() + tx_connected, _ = interface.is_device_connected() + if not tx_connected: + print("ERROR: TX device (master) not connected.") + sys.exit(1) + if not interface.txdevice.ping(): + print("ERROR: master module did not answer ping.") + sys.exit(1) + print(f" Master module connected " + f"(version {interface.txdevice.get_version(module=0)}).") + + dfu = STM32I2CDFUviaMaster(uart=interface.txdevice.uart, + i2c_addr=args.i2c_addr) + + # ------------------------------------------------------------------ + # Ping the slave DFU bootloader + # ------------------------------------------------------------------ + print(f"\nPinging slave DFU bootloader at 0x{args.i2c_addr:02X}...") + try: + blver = dfu.get_version() + blver = blver.decode(errors="replace") if isinstance(blver, (bytes, bytearray)) else blver + print(f" Bootloader version: {blver}") + except Exception as e: + print(f"ERROR: Slave DFU bootloader at 0x{args.i2c_addr:02X} did not respond: {e}") + print(" Make sure the slave module is powered and in DFU bootloader mode.") + sys.exit(1) + + # ------------------------------------------------------------------ + # Confirmation + # ------------------------------------------------------------------ + print() + if not args.yes: + answer = input( + f"Proceed with firmware update on slave 0x{args.i2c_addr:02X}? (y/n): " + ).strip().lower() + if answer != "y": + print("Aborted by user.") + sys.exit(0) + + # ------------------------------------------------------------------ + # Program: mass-erase app slot, write the raw signed image whole at the + # slot base, manifest, reset. The secure BL verifies the signature, + # decrypts and launches the application on the next boot. + # ------------------------------------------------------------------ + print(f"\nProgramming slave 0x{args.i2c_addr:02X}...") + try: + print(" mass-erasing slave application slot...") + dfu.mass_erase() + dfu.write_memory(SLOT_BASE, raw, progress_callback=_progress) + print(" manifest...") + dfu.manifest() + print(" resetting slave (secure BL verifies signature + launches app)...") + dfu.reset() + except Exception as e: + print(f"\nERROR: Programming failed — {e}") + sys.exit(1) + + # ------------------------------------------------------------------ + # Post-update version check via the normal application protocol + # ------------------------------------------------------------------ + print(f"Waiting {args.post_wait:.0f} s for slave to boot application...") + time.sleep(args.post_wait) + + try: + version = interface.txdevice.get_version(module=args.module) + print(f" Module {args.module} firmware version: {version}") + print("\nSLAVE I2C UPDATE COMPLETE") + except Exception as e: + print(f" WARNING: could not read module {args.module} version yet ({e}).") + print(" The bootloader may still be verifying/installing; " + "retry test_tx_getversion.py shortly.") + + +if __name__ == "__main__": + main() From 668306569e8e16530201cb5f7220a607d2f27985 Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Thu, 16 Jul 2026 14:24:29 -0400 Subject: [PATCH 08/68] sbom added and pinned repos --- pyproject.toml | 20 +-- sbom.cdx.json | 462 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 472 insertions(+), 10 deletions(-) create mode 100644 sbom.cdx.json diff --git a/pyproject.toml b/pyproject.toml index e6f2ab9..e4d3e4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [ description = "Openwater LIFU SDK — hardware I/O interface library" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.10" -license = "MIT" +license = "AGPL-3.0-only" license-files = ["LICENSE"] classifiers = [ "Development Status :: 4 - Beta", @@ -27,12 +27,12 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "numpy", - "pandas", - "xarray", - "pyserial", - "pyusb", - "base58" + "numpy==2.5.1", + "pandas==3.0.3", + "xarray==2026.7.0", + "pyserial==3.5", + "pyusb==1.3.1", + "base58==2.1.1" ] [project.optional-dependencies] @@ -47,9 +47,9 @@ dev = [ "pytest-mock", ] ui = [ - "PyQt6>=6.5", - "packaging>=21.0", - "requests>=2.25.0", + "PyQt6==6.11.0", + "packaging==26.2", + "requests==2.34.2", ] diff --git a/sbom.cdx.json b/sbom.cdx.json new file mode 100644 index 0000000..ae4251a --- /dev/null +++ b/sbom.cdx.json @@ -0,0 +1,462 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "serialNumber": "urn:uuid:f3828b7d-655c-47d4-b233-af39b9a8f62c", + "version": 1, + "metadata": { + "timestamp": "2026-07-16T00:00:00Z", + "authors": [ + { + "name": "Openwater Firmware Engineering" + } + ], + "tools": [ + { + "vendor": "Openwater", + "name": "curated-sbom", + "version": "1.0", + "notes": "Hand-curated CycloneDX SBOM. Python runtime dependencies and the ui extra are pinned exactly (==) in pyproject.toml [project.dependencies] / [project.optional-dependencies], so the versions recorded here are repository-enforced. The 'test'/'dev' extras are development-only and are not part of the distributed package. Transitive Python dependencies (certifi, urllib3, idna, charset-normalizer, python-dateutil, six, tzdata, PyQt6-Qt6, PyQt6_sip) are resolved at install time and are not enumerated. The bundled libusb DLL version is read from the DLLs Windows version resource. The bundled signed firmware images carry no readable version string (SBSFU-signed payloads); their provenance is the git history of src/openlifu_sdk/firmware and the SBOMs of their source repositories (openlifu-transmitter-fw, openlifu-console-fw)." + } + ], + "component": { + "bom-ref": "openwater-openlifu-sdk", + "type": "library", + "group": "cc.openwater", + "name": "openlifu-sdk", + "version": "2.0.14.post1.dev8", + "description": "Openwater LIFU SDK - Python hardware I/O interface library for the LIFU transmitter and console (USB CDC/DFU, serial protocol, module enumeration). Bundles signed application firmware images and Windows libusb-1.0 DLLs as package data.", + "publisher": "Openwater", + "licenses": [ + { + "license": { + "id": "AGPL-3.0-only" + } + } + ], + "purl": "pkg:pypi/openlifu-sdk@2.0.14.post1.dev8", + "properties": [ + { + "name": "openwater:git-commit", + "value": "1d179ac" + }, + { + "name": "openwater:version-source", + "value": "setuptools-scm from git tags (v); dev suffix indicates commits past the v2.0.14 tag" + } + ] + } + }, + "components": [ + { + "bom-ref": "numpy", + "type": "library", + "name": "numpy", + "supplier": { + "name": "NumPy Developers" + }, + "description": "Array computing for Python. Pinned exactly in pyproject.toml.", + "licenses": [ + { + "license": { + "id": "BSD-3-Clause" + } + } + ], + "purl": "pkg:pypi/numpy@2.5.1", + "properties": [ + { + "name": "openwater:declared-constraint", + "value": "numpy==2.5.1" + }, + { + "name": "openwater:version-basis", + "value": "pinned exactly in pyproject.toml" + } + ], + "version": "2.5.1" + }, + { + "bom-ref": "pandas", + "type": "library", + "name": "pandas", + "supplier": { + "name": "pandas Development Team" + }, + "description": "Data analysis library. Pinned exactly in pyproject.toml.", + "licenses": [ + { + "license": { + "id": "BSD-3-Clause" + } + } + ], + "purl": "pkg:pypi/pandas@3.0.3", + "properties": [ + { + "name": "openwater:declared-constraint", + "value": "pandas==3.0.3" + }, + { + "name": "openwater:version-basis", + "value": "pinned exactly in pyproject.toml" + } + ], + "version": "3.0.3" + }, + { + "bom-ref": "xarray", + "type": "library", + "name": "xarray", + "supplier": { + "name": "xarray Developers" + }, + "description": "N-D labeled arrays and datasets. Pinned exactly in pyproject.toml.", + "licenses": [ + { + "license": { + "id": "Apache-2.0" + } + } + ], + "purl": "pkg:pypi/xarray@2026.7.0", + "properties": [ + { + "name": "openwater:declared-constraint", + "value": "xarray==2026.7.0" + }, + { + "name": "openwater:version-basis", + "value": "pinned exactly in pyproject.toml" + } + ], + "version": "2026.7.0" + }, + { + "bom-ref": "pyserial", + "type": "library", + "name": "pyserial", + "supplier": { + "name": "Chris Liechti" + }, + "description": "Serial port access library, used for the LIFU UART/VCP protocol. Pinned exactly in pyproject.toml.", + "licenses": [ + { + "license": { + "id": "BSD-3-Clause" + } + } + ], + "purl": "pkg:pypi/pyserial@3.5", + "properties": [ + { + "name": "openwater:declared-constraint", + "value": "pyserial==3.5" + }, + { + "name": "openwater:version-basis", + "value": "pinned exactly in pyproject.toml" + } + ], + "version": "3.5" + }, + { + "bom-ref": "pyusb", + "type": "library", + "name": "pyusb", + "supplier": { + "name": "PyUSB Contributors" + }, + "description": "USB access library (libusb front end), used for DFU and device discovery. Pinned exactly in pyproject.toml.", + "licenses": [ + { + "license": { + "id": "BSD-3-Clause" + } + } + ], + "purl": "pkg:pypi/pyusb@1.3.1", + "properties": [ + { + "name": "openwater:declared-constraint", + "value": "pyusb==1.3.1" + }, + { + "name": "openwater:version-basis", + "value": "pinned exactly in pyproject.toml" + } + ], + "version": "1.3.1" + }, + { + "bom-ref": "base58", + "type": "library", + "name": "base58", + "supplier": { + "name": "David Keijser" + }, + "description": "Base58 encoding, used for device identifiers. Pinned exactly in pyproject.toml.", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:pypi/base58@2.1.1", + "properties": [ + { + "name": "openwater:declared-constraint", + "value": "base58==2.1.1" + }, + { + "name": "openwater:version-basis", + "value": "pinned exactly in pyproject.toml" + } + ], + "version": "2.1.1" + }, + { + "bom-ref": "pyqt6", + "type": "library", + "name": "PyQt6", + "scope": "optional", + "supplier": { + "name": "Riverbank Computing" + }, + "description": "Qt 6 bindings, pulled in only by the 'ui' extra (pip install openlifu-sdk[ui]).", + "licenses": [ + { + "license": { + "id": "GPL-3.0-only" + } + } + ], + "purl": "pkg:pypi/pyqt6@6.11.0", + "properties": [ + { + "name": "openwater:declared-constraint", + "value": "PyQt6==6.11.0 (optional-dependencies: ui)" + }, + { + "name": "openwater:license-note", + "value": "GPL-3.0 (or Riverbank commercial license) - only linked when the optional ui extra is installed" + }, + { + "name": "openwater:version-basis", + "value": "pinned exactly in pyproject.toml" + } + ], + "version": "6.11.0" + }, + { + "bom-ref": "packaging", + "type": "library", + "name": "packaging", + "scope": "optional", + "supplier": { + "name": "Python Packaging Authority" + }, + "description": "Version parsing utilities, pulled in only by the 'ui' extra.", + "licenses": [ + { + "license": { + "id": "Apache-2.0" + } + }, + { + "license": { + "id": "BSD-2-Clause" + } + } + ], + "purl": "pkg:pypi/packaging@26.2", + "properties": [ + { + "name": "openwater:declared-constraint", + "value": "packaging==26.2 (optional-dependencies: ui)" + }, + { + "name": "openwater:version-basis", + "value": "pinned exactly in pyproject.toml" + } + ], + "version": "26.2" + }, + { + "bom-ref": "requests", + "type": "library", + "name": "requests", + "scope": "optional", + "supplier": { + "name": "Python Software Foundation" + }, + "description": "HTTP client, pulled in only by the 'ui' extra (firmware download/update checks).", + "licenses": [ + { + "license": { + "id": "Apache-2.0" + } + } + ], + "purl": "pkg:pypi/requests@2.34.2", + "properties": [ + { + "name": "openwater:declared-constraint", + "value": "requests==2.34.2 (optional-dependencies: ui)" + }, + { + "name": "openwater:version-basis", + "value": "pinned exactly in pyproject.toml" + } + ], + "version": "2.34.2" + }, + { + "bom-ref": "libusb", + "type": "library", + "group": "org.libusb", + "name": "libusb-1.0 (Windows DLLs)", + "version": "1.0.29", + "supplier": { + "name": "libusb project" + }, + "description": "Prebuilt libusb-1.0 Windows binaries redistributed as package data (src/openlifu_sdk/libusb/win64 and win32), used by pyusb as its native backend.", + "licenses": [ + { + "license": { + "id": "LGPL-2.1-or-later" + } + } + ], + "purl": "pkg:github/libusb/libusb@v1.0.29", + "properties": [ + { + "name": "openwater:evidence", + "value": "DLL Windows version resource: FileVersion 1.0.29.11953 (both win64 and win32); LegalCopyright: GNU LGPL v2.1 or later" + } + ] + }, + { + "bom-ref": "bundled-transmitter-fw", + "type": "firmware", + "group": "cc.openwater", + "name": "openlifu-transmitter-fw (signed image)", + "supplier": { + "name": "Openwater" + }, + "publisher": "Openwater", + "description": "SBSFU-signed LIFU transmitter application firmware image redistributed as package data (src/openlifu_sdk/firmware/openlifu-transmitter-fw.signed.bin) for field DFU updates. The signed payload carries no readable version string; see the openlifu-transmitter-fw repository and its sbom.cdx.json for the source composition.", + "licenses": [ + { + "license": { + "id": "AGPL-3.0-only" + } + } + ], + "properties": [ + { + "name": "openwater:evidence", + "value": "git log src/openlifu_sdk/firmware: last updated in commit 8e0e19c ('updated current firmware...')" + } + ] + }, + { + "bom-ref": "bundled-console-fw", + "type": "firmware", + "group": "cc.openwater", + "name": "openlifu-console-fw (signed image)", + "supplier": { + "name": "Openwater" + }, + "publisher": "Openwater", + "description": "SBSFU-signed LIFU console application firmware image redistributed as package data (src/openlifu_sdk/firmware/openlifu-console-fw.signed.bin) for field DFU updates. The signed payload carries no readable version string; see the openlifu-console-fw repository and its sbom.cdx.json for the source composition.", + "licenses": [ + { + "license": { + "id": "AGPL-3.0-only" + } + } + ], + "properties": [ + { + "name": "openwater:evidence", + "value": "git log src/openlifu_sdk/firmware: last updated in commit 8e0e19c ('updated current firmware...')" + } + ] + } + ], + "dependencies": [ + { + "ref": "openwater-openlifu-sdk", + "dependsOn": [ + "numpy", + "pandas", + "xarray", + "pyserial", + "pyusb", + "base58", + "pyqt6", + "packaging", + "requests", + "libusb", + "bundled-transmitter-fw", + "bundled-console-fw" + ] + }, + { + "ref": "pandas", + "dependsOn": [ + "numpy" + ] + }, + { + "ref": "xarray", + "dependsOn": [ + "numpy", + "pandas" + ] + }, + { + "ref": "pyusb", + "dependsOn": [ + "libusb" + ] + }, + { + "ref": "numpy", + "dependsOn": [] + }, + { + "ref": "pyserial", + "dependsOn": [] + }, + { + "ref": "base58", + "dependsOn": [] + }, + { + "ref": "pyqt6", + "dependsOn": [] + }, + { + "ref": "packaging", + "dependsOn": [] + }, + { + "ref": "requests", + "dependsOn": [] + }, + { + "ref": "libusb", + "dependsOn": [] + }, + { + "ref": "bundled-transmitter-fw", + "dependsOn": [] + }, + { + "ref": "bundled-console-fw", + "dependsOn": [] + } + ] +} \ No newline at end of file From 4dfba6f3e31d61c50450b3ae71eda84b76f72911 Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Thu, 16 Jul 2026 14:45:27 -0400 Subject: [PATCH 09/68] static analysis --- .github/workflows/safety-security-scan.yml | 85 ++++++++++++++++++++++ lizard_whitelist.csv | 28 +++++++ src/openlifu_sdk/io/LIFUHVController.py | 1 - src/openlifu_sdk/io/LIFUInterface.py | 1 - src/openlifu_sdk/io/LIFUTXDevice.py | 29 ++------ src/openlifu_sdk/io/component.py | 3 - src/openlifu_sdk/ui/status_frame.py | 1 - src/openlifu_sdk/ui/version_check.py | 2 +- 8 files changed, 120 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/safety-security-scan.yml create mode 100644 lizard_whitelist.csv diff --git a/.github/workflows/safety-security-scan.yml b/.github/workflows/safety-security-scan.yml new file mode 100644 index 0000000..7bd5c74 --- /dev/null +++ b/.github/workflows/safety-security-scan.yml @@ -0,0 +1,85 @@ +name: "Medical Software Safety & Security Scan" + +on: + push: + branches: [ main, next ] + pull_request: + branches: [ main ] + +jobs: + static-analysis: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Analysis Tools + run: pip install ruff bandit lizard + + # 1. Ruff (correctness / dead code / import hygiene) + # Python counterpart of the firmware repos' cppcheck gate; any finding + # fails the build. Suppressions are inline (# noqa: -- reason). + - name: Run Ruff (Correctness) + run: ruff check src + + # 2. Bandit (security static analysis) + # Gates on MEDIUM and higher severity. LOW findings (e.g. B110 + # try/except/pass in device-cleanup paths) are reported in the log but + # do not fail the build. + - name: Run Bandit (Security) + run: bandit -r src --severity-level medium + + # 3. Complexity Analysis (Lizard) + # IEC 62304 recommends low cyclomatic complexity for testability. + # Same thresholds as the firmware repos (-C 15 -L 100); functions that + # are structurally complex by design are whitelisted in + # lizard_whitelist.csv. + - name: Check Code Complexity (IEC 62304 Requirement) + run: | + lizard src \ + -C 15 -L 100 -w \ + --whitelist lizard_whitelist.csv + + # 4. Vulnerability Scan against the committed SBOM + # grype consumes the CycloneDX SBOM and checks each component against + # the Grype vulnerability database (NVD, OSV, GHSA, and others). + # Dependency versions are pinned in pyproject.toml and mirrored in the + # SBOM, so PyPI purl matching is exact. + # Fails the build on any HIGH or CRITICAL finding. + - name: Scan SBOM for Vulnerabilities (grype) + id: grype + uses: anchore/scan-action@v6 + with: + sbom: "sbom.cdx.json" + fail-build: true + severity-cutoff: high + output-format: sarif + + # 5. Upload SARIF results to GitHub Security tab + # Makes vulnerability findings visible in the repository's Security view. + # Code Scanning (Security tab) requires GitHub Advanced Security, which is + # off by default on private repos. Don't fail the scan if it isn't enabled: + # grype already gates the build on HIGH/CRITICAL CVEs, and the SARIF is kept + # as a build artifact below. Remove continue-on-error once GHAS is enabled. + - name: Upload SARIF to GitHub Security + if: always() && steps.grype.conclusion != 'skipped' + continue-on-error: true + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: ${{ steps.grype.outputs.sarif }} + + # 6. Upload Audit Artifacts + # Keeps a record of the scan results for regulatory submission. + - name: Archive Regulatory Evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: sdk-compliance-report + path: | + sbom.cdx.json + ${{ steps.grype.outputs.sarif }} diff --git a/lizard_whitelist.csv b/lizard_whitelist.csv new file mode 100644 index 0000000..dca496b --- /dev/null +++ b/lizard_whitelist.csv @@ -0,0 +1,28 @@ +# lizard complexity/length whitelist (IEC 62304) +# Structurally large/branchy by design; covered by review + tests. +# One function name per line (lizard matches on the bare name). + +# Unit-conversion lookup ladders (util/units.py) — flat unit/prefix mapping +# tables expressed as if/elif chains; CCN comes from the number of units, +# not nesting. +getunitconversion +getsiscale + +# Checked-command send path (io/component.py) — parameter-validation ladder +# plus retry/timeout handling kept in one function to preserve the +# request/response pairing (CCN 18, 9 params). +send_checked + +# DFU module-update orchestration (io/LIFUDFU.py) — sequential update steps +# with per-step error reporting; length from step count, not nesting. +update_module + +# Trigger configuration (io/LIFUTXDevice.py) — validates and packs the full +# trigger parameter set in one place to keep the JSON payload atomic. +set_trigger + +# UART async sender loop and sync response reader (io/uart.py) — protocol +# state machines; per-state dispatch kept inline to preserve the framing +# handshake. +_sender_loop +_read_response_sync diff --git a/src/openlifu_sdk/io/LIFUHVController.py b/src/openlifu_sdk/io/LIFUHVController.py index 6103dea..f67b4a2 100644 --- a/src/openlifu_sdk/io/LIFUHVController.py +++ b/src/openlifu_sdk/io/LIFUHVController.py @@ -10,7 +10,6 @@ GLOBAL_COMMANDS, LIFU_ERR_BAD_PAYLOAD_LENGTH, OW_CONSOLE_PID, - OW_ERROR, OW_POWER, OW_POWER_12V_OFF, OW_POWER_12V_ON, diff --git a/src/openlifu_sdk/io/LIFUInterface.py b/src/openlifu_sdk/io/LIFUInterface.py index 2f658a7..9970049 100644 --- a/src/openlifu_sdk/io/LIFUInterface.py +++ b/src/openlifu_sdk/io/LIFUInterface.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import importlib.metadata import logging import os diff --git a/src/openlifu_sdk/io/LIFUTXDevice.py b/src/openlifu_sdk/io/LIFUTXDevice.py index da2756c..ac9b9f4 100644 --- a/src/openlifu_sdk/io/LIFUTXDevice.py +++ b/src/openlifu_sdk/io/LIFUTXDevice.py @@ -4,16 +4,14 @@ import logging import re import struct -import time from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Annotated, Dict, List, Literal, Optional +from typing import TYPE_CHECKING, Annotated, Dict, List, Literal import numpy as np from openlifu_sdk.io.component import OWComponent, register_command_packet_types from openlifu_sdk.util.annotations import OpenLIFUFieldData from openlifu_sdk.util.units import getunitconversion -from openlifu_sdk.util.hwid import format_hwid DEFAULT_NUM_TRANSMITTERS = 2 TRANSMITTERS_PER_MODULE = 2 @@ -106,7 +104,7 @@ DEFAULT_PULSE_WIDTH_US = 20 TEMPERATURE_DATA_LENGTH = 4 -from openlifu_sdk.io.LIFUConfig import ( +from openlifu_sdk.io.LIFUConfig import ( # noqa: E402 -- protocol constants above are needed before this import block CONTROLLER_COMMANDS, DEFAULT_TIMEOUT, GLOBAL_COMMANDS, @@ -114,30 +112,18 @@ LIFU_ERR_BAD_PAYLOAD_LENGTH, LIFU_ERR_EMPTY_RESPONSE, LIFU_ERR_MODULE_COUNT_MISMATCH, - OW_CMD, OW_CMD_ASYNC, - OW_CMD_DFU, - OW_CMD_ECHO, OW_CMD_GET_AMBIENT, OW_CTRL_GET_MODULE_COUNT, OW_CTRL_GET_MODULE_MODE, OW_CTRL_ENUMERATE, - NODE_MODE_APP, - NODE_MODE_BOOTLOADER, NODE_MODE_UNKNOWN, OW_CMD_GET_TEMP, - OW_CMD_HWID, - OW_CMD_PING, - OW_CMD_RESET, - OW_CMD_TOGGLE_LED, - OW_CMD_USR_CFG, - OW_CMD_VERSION, OW_CONTROLLER, OW_CTRL_GET_SWTRIG, OW_CTRL_SET_SWTRIG, OW_CTRL_START_SWTRIG, OW_CTRL_STOP_SWTRIG, - OW_ERROR, OW_TRANSMITTER_PID, OW_TX7332, OW_TX7332_DEMO, @@ -145,18 +131,16 @@ OW_TX7332_ENUM, OW_TX7332_RREG, OW_TX7332_RBLOCK, - OW_TX7332_VWBLOCK, - OW_TX7332_VWREG, OW_TX7332_WBLOCK, OW_TX7332_WREG, OW_VID, TRIGGER_MODE_CONTINUOUS, TRIGGER_MODE_SEQUENCE, TRIGGER_MODE_SINGLE, - HW_ID_DATA_LENGTH, TX7332_COMMANDS ) -from openlifu_sdk.io.exceptions import LIFUError, LIFUProtocolError +from openlifu_sdk.io.LIFUConfig import HW_ID_DATA_LENGTH as HW_ID_DATA_LENGTH # noqa: E402 -- re-exported; unit tests import it from this module +from openlifu_sdk.io.exceptions import LIFUError, LIFUProtocolError # noqa: E402 if TYPE_CHECKING: pass @@ -661,8 +645,8 @@ def set_solution(self, if n > 1: # Buffer the pulse and delay profiles in the microcontroller(s), so that they can be used to switch profiles on trigger detection - delay_control_registers = {profile: self.tx_registers.get_delay_control_registers(profile) for profile in self.tx_registers.configured_delay_profiles()} - pulse_control_registers = {profile: self.tx_registers.get_pulse_control_registers(profile) for profile in self.tx_registers.configured_pulse_profiles()} + delay_control_registers = {profile: self.tx_registers.get_delay_control_registers(profile) for profile in self.tx_registers.configured_delay_profiles()} # noqa: F841 -- consumed by the profile-buffering feature in development on a branch + pulse_control_registers = {profile: self.tx_registers.get_pulse_control_registers(profile) for profile in self.tx_registers.configured_pulse_profiles()} # noqa: F841 -- consumed by the profile-buffering feature in development on a branch return True @@ -1235,7 +1219,6 @@ def get_pulse_control_registers(self, profile: int | None=None, pulse_invert: bo y = pattern['y']*int(cycles+1) y = y[:(16*elastic_repeat)] y = y + ([0]*pulse_profile.tail_count) - t = np.arange(len(y))*(1/clk_n) elastic_mode = 1 if elastic_repeat > MAX_ELASTIC_REPEAT: raise ValueError("Pattern duration too long for elastic repeat") diff --git a/src/openlifu_sdk/io/component.py b/src/openlifu_sdk/io/component.py index cf7306b..e488907 100644 --- a/src/openlifu_sdk/io/component.py +++ b/src/openlifu_sdk/io/component.py @@ -87,9 +87,6 @@ def signal_error(self) -> OWSignal: def connect(self) -> bool: return self._uart.connect() - - def is_connected(self) -> bool: - return self._uart.is_connected def disconnect(self): self._uart.disconnect() diff --git a/src/openlifu_sdk/ui/status_frame.py b/src/openlifu_sdk/ui/status_frame.py index a81b6fc..aebdd55 100644 --- a/src/openlifu_sdk/ui/status_frame.py +++ b/src/openlifu_sdk/ui/status_frame.py @@ -14,7 +14,6 @@ import logging import re -from typing import Optional logger = logging.getLogger(__name__) diff --git a/src/openlifu_sdk/ui/version_check.py b/src/openlifu_sdk/ui/version_check.py index 3ba456f..f190c3c 100644 --- a/src/openlifu_sdk/ui/version_check.py +++ b/src/openlifu_sdk/ui/version_check.py @@ -11,7 +11,7 @@ import logging import re import sys -from typing import Optional, Tuple +from typing import Tuple logger = logging.getLogger(__name__) From 911ff125a634b0b4dc169e6cf7603cb52ad60cd5 Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Thu, 16 Jul 2026 15:40:13 -0400 Subject: [PATCH 10/68] fix version --- .gitignore | 1 + pyproject.toml | 12 +++++++++--- src/openlifu_sdk/__init__.py | 15 +++++++++++++++ src/openlifu_sdk/io/LIFUInterface.py | 10 +++++++++- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index ae14651..99dc27e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ htmlcov/ #Downloaded Firmware src/openlifu_sdk/firmware/downloads +src/openlifu_sdk/_version.py diff --git a/pyproject.toml b/pyproject.toml index e4d3e4c..a38a198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,13 @@ openlifu_sdk = [ [tool.setuptools_scm] -# Uses tags like v1.2.3 or 1.2.3 -> 1.2.3 -tag_regex = "^(?:pre-)?v?(?P\\d+\\.\\d+\\.\\d+)$" +# The version comes from the git tag, verbatim. Accepts release and +# pre-release tags with optional pre-/v prefixes, e.g.: +# 1.2.3 v1.2.3 pre-v1.2.3 2.0.15-rc.0 2.0.15rc0 1.2.3.post1 +# The captured part is normalized per PEP 440 (2.0.15-rc.0 -> 2.0.15rc0). +tag_regex = "^(?:pre-)?v?(?P\\d+\\.\\d+\\.\\d+(?:[-._]?(?:rc|a|b|c|alpha|beta|post|dev)[-._]?\\d*)?)$" version_scheme = "no-guess-dev" -local_scheme = "no-local-version" \ No newline at end of file +local_scheme = "no-local-version" +# Bake the resolved version into the package so openlifu_sdk.__version__ +# always matches the wheel that was actually imported. +version_file = "src/openlifu_sdk/_version.py" \ No newline at end of file diff --git a/src/openlifu_sdk/__init__.py b/src/openlifu_sdk/__init__.py index d87852a..baa7303 100644 --- a/src/openlifu_sdk/__init__.py +++ b/src/openlifu_sdk/__init__.py @@ -1,8 +1,23 @@ from __future__ import annotations +try: + # Written by setuptools-scm at build time (version_file in pyproject.toml); + # reflects the git tag the distribution was built from. + from openlifu_sdk._version import __version__ +except ImportError: + # Source-tree / editable use without a build: fall back to the installed + # distribution metadata, or a placeholder when not installed at all. + try: + from importlib.metadata import PackageNotFoundError, version + + __version__ = version("openlifu-sdk") + except PackageNotFoundError: + __version__ = "0.0.0+unknown" + from openlifu_sdk.io.LIFUInterface import LIFUInterface, LIFUInterfaceStatus __all__ = [ "LIFUInterface", "LIFUInterfaceStatus", + "__version__", ] diff --git a/src/openlifu_sdk/io/LIFUInterface.py b/src/openlifu_sdk/io/LIFUInterface.py index 9970049..9dd16a0 100644 --- a/src/openlifu_sdk/io/LIFUInterface.py +++ b/src/openlifu_sdk/io/LIFUInterface.py @@ -669,4 +669,12 @@ def _release_hw_interface_pid(self) -> None: @staticmethod def get_sdk_version() -> str: - return importlib.metadata.version("openlifu-sdk") + # Report the version of the module actually imported (baked from the + # git tag at build time), not whichever dist-info importlib finds + # first -- the two can disagree when a stale install lingers on the + # path. Runtime import avoids a circular import at module load. + import openlifu_sdk + + return getattr( + openlifu_sdk, "__version__", None + ) or importlib.metadata.version("openlifu-sdk") From 0640d79358ea7a6bdef3f6e0b9ba8d64e681e0b3 Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Mon, 20 Jul 2026 10:48:19 -0400 Subject: [PATCH 11/68] added rgb effects --- examples/test_console_rgb.py | 126 ++++++++++++++++++++++++ src/openlifu_sdk/io/LIFUConfig.py | 13 ++- src/openlifu_sdk/io/LIFUHVController.py | 126 +++++++++++++++++++++++- 3 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 examples/test_console_rgb.py diff --git a/examples/test_console_rgb.py b/examples/test_console_rgb.py new file mode 100644 index 0000000..7cea3cd --- /dev/null +++ b/examples/test_console_rgb.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import sys +import time + +from openlifu_sdk.io.LIFUInterface import LIFUInterface + +# Exercises the console RGB driver end-to-end: +# 1. Legacy enum API (set_rgb_led / get_rgb_led) - backward compatibility +# 2. Static 24-bit colors (set_rgb_color) +# 3. Effects: fade, breathe, rainbow, flash, color cycle, stop +# +# Requires console firmware with OW_POWER_SET_RGB_FX support (>= 1.0.2). +# +# set PYTHONPATH=%cd%\src;%PYTHONPATH% +# python examples/test_console_rgb.py + +print("Starting Console RGB Test Script...") + +interface = LIFUInterface() +tx_connected, hv_connected = interface.is_device_connected() + +if not hv_connected: + print("❌ Console (HV controller) not connected.") + sys.exit(1) + +print("✅ Console connected.") +hv = interface.hvcontroller +hv.ping() + +# --------------------------------------------------------------------------- +# 1. Backward compatibility: legacy enum API +# --------------------------------------------------------------------------- +print("\n--- Legacy enum API (set_rgb_led / get_rgb_led) ---") +for state, name in ((1, "RED"), (2, "GREEN"), (3, "BLUE"), (0, "OFF")): + print(f"set_rgb_led({state}) -> {name}") + hv.set_rgb_led(state) + readback = hv.get_rgb_led() + assert readback == state, f"get_rgb_led returned {readback}, expected {state}" + time.sleep(1.0) +print("Legacy API OK (set/get round-trip verified)") + +# --------------------------------------------------------------------------- +# 2. Static 24-bit colors (beyond the legacy 4 states) +# --------------------------------------------------------------------------- +print("\n--- Static 24-bit colors (set_rgb_color) ---") +static_colors = [ + ((255, 96, 0), "orange"), + ((255, 0, 255), "magenta"), + ((0, 255, 255), "cyan"), + ((255, 255, 255), "white"), + ((32, 32, 32), "dim white (gamma low end)"), +] +for (r, g, b), name in static_colors: + print(f"set_rgb_color({r}, {g}, {b}) -> {name}") + hv.set_rgb_color(r, g, b) + time.sleep(1.2) + +# --------------------------------------------------------------------------- +# 3. Effects - each runs on the device; the host just watches +# --------------------------------------------------------------------------- +print("\n--- Fade ---") +print("rgb_fade_to(255, 0, 0, 1500ms) -> fade to red") +hv.rgb_fade_to(255, 0, 0, 1500) +time.sleep(2.0) +print("rgb_fade_to(0, 0, 255, 1500ms) -> cross-fade to blue") +hv.rgb_fade_to(0, 0, 255, 1500) +time.sleep(2.0) +print("rgb_fade_to(0, 0, 0, 1000ms) -> smooth off") +hv.rgb_fade_to(0, 0, 0, 1000) +time.sleep(1.5) + +print("\n--- Breathe ---") +print("rgb_breathe(0, 160, 255, 3000ms) for 3 breaths") +hv.rgb_breathe(0, 160, 255, 3000) +time.sleep(9.0) + +print("\n--- Rainbow ---") +print("rgb_rainbow(4000ms) for 2 revolutions") +hv.rgb_rainbow(4000) +time.sleep(8.0) + +print("\n--- Flash ---") +print("rgb_flash(255, 0, 0, 1000ms) for 3 cycles") +hv.rgb_flash(255, 0, 0, 1000) +time.sleep(3.0) + +print("\n--- Color cycle ---") +print("rgb_color_cycle(R, G, B, yellow, magenta; 700ms dwell)") +hv.rgb_color_cycle( + [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0), (255, 0, 255)], + dwell_ms=700, +) +time.sleep(7.0) + +print("\n--- Stop mid-effect ---") +print("rgb_effect_stop() -> LED freezes on whatever the cycle was showing") +hv.rgb_effect_stop() +time.sleep(2.0) + +# --------------------------------------------------------------------------- +# 4. Error handling: out-of-range requests must be rejected locally +# --------------------------------------------------------------------------- +print("\n--- Parameter validation ---") +for bad_call, kwargs in ( + ("set_rgb_led(7)", lambda: hv.set_rgb_led(7)), + ("set_rgb_color(300, 0, 0)", lambda: hv.set_rgb_color(300, 0, 0)), + ("rgb_flash period 70000", lambda: hv.rgb_flash(255, 0, 0, 70000)), + ("rgb_color_cycle 9 colors", lambda: hv.rgb_color_cycle([(1, 2, 3)] * 9)), +): + try: + kwargs() + print(f"❌ {bad_call} was NOT rejected") + sys.exit(1) + except ValueError as e: + print(f"OK: {bad_call} rejected ({e})") + +# --------------------------------------------------------------------------- +# 5. Legacy API still works after effects (cancels any residue) +# --------------------------------------------------------------------------- +print("\n--- Restore normal state ---") +print("set_rgb_led(2) -> GREEN (idle indication)") +hv.set_rgb_led(2) +assert hv.get_rgb_led() == 2 + +print("\n✅ Console RGB test complete - LED should be solid green.") diff --git a/src/openlifu_sdk/io/LIFUConfig.py b/src/openlifu_sdk/io/LIFUConfig.py index 00a0412..cbb5b5a 100644 --- a/src/openlifu_sdk/io/LIFUConfig.py +++ b/src/openlifu_sdk/io/LIFUConfig.py @@ -94,6 +94,17 @@ OW_POWER_VMON = 0x40 OW_POWER_RAW_DAC = 0x41 OW_POWER_HV_ENABLE = 0x42 +OW_POWER_SET_RGB_FX = 0x43 + +# OW_POWER_SET_RGB_FX effect ids (payload byte 0 - see firmware common.h) +OW_RGB_FX_STOP = 0 +OW_RGB_FX_SOLID = 1 +OW_RGB_FX_FADE = 2 +OW_RGB_FX_BREATHE = 3 +OW_RGB_FX_RAINBOW = 4 +OW_RGB_FX_FLASH = 5 +OW_RGB_FX_CYCLE = 6 +OW_RGB_CYCLE_MAX_COLORS = 8 # --------------------------------------------------------------------------- # Command sets – used by LIFU to validate commands per component @@ -123,7 +134,7 @@ OW_POWER_HV_OFF, OW_POWER_12V_ON, OW_POWER_12V_OFF, OW_POWER_GET_TEMP1, OW_POWER_GET_TEMP2, OW_POWER_SET_FAN, OW_POWER_GET_FAN, OW_POWER_SET_RGB, OW_POWER_GET_RGB, OW_POWER_GET_HVON, OW_POWER_GET_12VON, OW_POWER_SET_DACS, - OW_POWER_VMON, OW_POWER_RAW_DAC, OW_POWER_HV_ENABLE, + OW_POWER_VMON, OW_POWER_RAW_DAC, OW_POWER_HV_ENABLE, OW_POWER_SET_RGB_FX, } TRIGGER_MODE_SEQUENCE = 0 diff --git a/src/openlifu_sdk/io/LIFUHVController.py b/src/openlifu_sdk/io/LIFUHVController.py index f67b4a2..b5e8f71 100644 --- a/src/openlifu_sdk/io/LIFUHVController.py +++ b/src/openlifu_sdk/io/LIFUHVController.py @@ -28,7 +28,16 @@ OW_POWER_SET_FAN, OW_POWER_SET_HV, OW_POWER_SET_RGB, + OW_POWER_SET_RGB_FX, OW_POWER_VMON, + OW_RGB_CYCLE_MAX_COLORS, + OW_RGB_FX_BREATHE, + OW_RGB_FX_CYCLE, + OW_RGB_FX_FADE, + OW_RGB_FX_FLASH, + OW_RGB_FX_RAINBOW, + OW_RGB_FX_SOLID, + OW_RGB_FX_STOP, OW_VID, POWER_COMMANDS, ) @@ -317,7 +326,7 @@ def get_fan_speed(self, fan_id: int = 0) -> int: return r.data[0] def set_rgb_led(self, rgb_state: int) -> bool: - """Set the RGB LED state (0 = OFF, 1 = RED, 2 = BLUE, 3 = GREEN). + """Set the RGB LED state (0 = OFF, 1 = RED, 2 = GREEN, 3 = BLUE). Raises: ValueError: If *rgb_state* is out of range. @@ -325,7 +334,7 @@ def set_rgb_led(self, rgb_state: int) -> bool: """ if rgb_state not in (0, 1, 2, 3): raise ValueError( - "Invalid RGB state. Must be 0 (OFF), 1 (RED), 2 (BLUE), or 3 (GREEN)" + "Invalid RGB state. Must be 0 (OFF), 1 (RED), 2 (GREEN), or 3 (BLUE)" ) self.send_checked(packet_type=OW_POWER, command=OW_POWER_SET_RGB, reserved=rgb_state, op="set_rgb_led") @@ -334,6 +343,9 @@ def set_rgb_led(self, rgb_state: int) -> bool: def get_rgb_led(self) -> int: """Read the RGB LED state. + Note: reflects the last basic set_rgb_led() state only; rgb_* effect + methods do not change it. + Raises: LIFUNotConnectedError, LIFUCommunicationError, LIFUDeviceError. """ @@ -341,6 +353,116 @@ def get_rgb_led(self) -> int: op="get_rgb_led") return r.reserved + # ------------------------------------------------------------------ + # RGB effects (OW_POWER_SET_RGB_FX). The console drives the LED with a + # DMA-based 24-bit color engine; these commands select an animation that + # then runs entirely on the device. Basic set_rgb_led() remains valid + # and cancels any running effect. + # ------------------------------------------------------------------ + + @staticmethod + def _check_rgb(r: int, g: int, b: int, period_ms: int) -> None: + for name, v in (("r", r), ("g", g), ("b", b)): + if not 0 <= v <= 255: + raise ValueError(f"Invalid {name} value {v}. Must be 0-255") + if not 0 <= period_ms <= 0xFFFF: + raise ValueError(f"Invalid period {period_ms}. Must be 0-65535 ms") + + def _send_rgb_fx(self, fx: int, r: int = 0, g: int = 0, b: int = 0, + period_ms: int = 0, extra: bytes = b"", op: str = "rgb_fx") -> bool: + payload = bytearray(struct.pack(" bool: + """Set a static 24-bit LED color, cancelling any running effect. + + Raises: + ValueError: If a channel value is out of range. + LIFUNotConnectedError, LIFUCommunicationError, LIFUDeviceError. + """ + self._check_rgb(r, g, b, 0) + return self._send_rgb_fx(OW_RGB_FX_SOLID, r, g, b, op="set_rgb_color") + + def rgb_fade_to(self, r: int, g: int, b: int, duration_ms: int = 1000) -> bool: + """Fade smoothly from the current color to (r, g, b) over duration_ms, + then hold. Fading to (0, 0, 0) is a smooth off. + + Raises: + ValueError: If a parameter is out of range. + LIFUNotConnectedError, LIFUCommunicationError, LIFUDeviceError. + """ + self._check_rgb(r, g, b, duration_ms) + return self._send_rgb_fx(OW_RGB_FX_FADE, r, g, b, duration_ms, + op="rgb_fade_to") + + def rgb_breathe(self, r: int, g: int, b: int, period_ms: int = 3000) -> bool: + """Breathe the given color: brightness ramps 0 -> full -> 0 every + period_ms, repeating until another command. + + Raises: + ValueError: If a parameter is out of range. + LIFUNotConnectedError, LIFUCommunicationError, LIFUDeviceError. + """ + self._check_rgb(r, g, b, period_ms) + return self._send_rgb_fx(OW_RGB_FX_BREATHE, r, g, b, period_ms, + op="rgb_breathe") + + def rgb_rainbow(self, period_ms: int = 4000) -> bool: + """Sweep the full hue wheel, one revolution every period_ms, repeating. + + Raises: + ValueError: If *period_ms* is out of range. + LIFUNotConnectedError, LIFUCommunicationError, LIFUDeviceError. + """ + self._check_rgb(0, 0, 0, period_ms) + return self._send_rgb_fx(OW_RGB_FX_RAINBOW, period_ms=period_ms, + op="rgb_rainbow") + + def rgb_flash(self, r: int, g: int, b: int, period_ms: int = 1000) -> bool: + """Flash the given color: 50% on/off blink with a full cycle of + period_ms (e.g. 1000 = 0.5 s on, 0.5 s off), repeating. + + Raises: + ValueError: If a parameter is out of range. + LIFUNotConnectedError, LIFUCommunicationError, LIFUDeviceError. + """ + self._check_rgb(r, g, b, period_ms) + return self._send_rgb_fx(OW_RGB_FX_FLASH, r, g, b, period_ms, + op="rgb_flash") + + def rgb_color_cycle(self, colors: list[tuple[int, int, int]], + dwell_ms: int = 1000) -> bool: + """Step through a list of colors, showing each for dwell_ms, repeating. + + Args: + colors: 1 to 8 (r, g, b) tuples. + dwell_ms: Time each color is shown, in milliseconds. + + Raises: + ValueError: If the color list or a value is out of range. + LIFUNotConnectedError, LIFUCommunicationError, LIFUDeviceError. + """ + if not 1 <= len(colors) <= OW_RGB_CYCLE_MAX_COLORS: + raise ValueError( + f"Invalid color list. Must contain 1-{OW_RGB_CYCLE_MAX_COLORS} colors" + ) + for color in colors: + self._check_rgb(*color, dwell_ms) + first = colors[0] + extra = b"".join(struct.pack(" bool: + """Cancel any running effect; the LED holds its current color. + + Raises: + LIFUNotConnectedError, LIFUCommunicationError, LIFUDeviceError. + """ + return self._send_rgb_fx(OW_RGB_FX_STOP, op="rgb_effect_stop") + def get_vmon_values(self) -> list[dict]: """Retrieve the voltage-monitor readings. From 1ba01f9a6c3a2e19e3a983f3c33105cf31a3731a Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Mon, 20 Jul 2026 13:20:52 -0400 Subject: [PATCH 12/68] signing capabilities added to the sdk --- examples/test_console_dfu.py | 99 +++-- pyproject.toml | 4 + src/openlifu_sdk/io/LIFUCrypto.py | 617 ++++++++++++++++++++++++++++++ src/openlifu_sdk/io/LIFUDFU.py | 170 +++++++- 4 files changed, 845 insertions(+), 45 deletions(-) create mode 100644 src/openlifu_sdk/io/LIFUCrypto.py diff --git a/examples/test_console_dfu.py b/examples/test_console_dfu.py index 347a3ab..e29f477 100644 --- a/examples/test_console_dfu.py +++ b/examples/test_console_dfu.py @@ -1,53 +1,76 @@ from __future__ import annotations +import argparse import sys -from time import sleep +import time +from openlifu_sdk.io.LIFUDFU import LIFUDFUManager from openlifu_sdk.io.LIFUInterface import LIFUInterface +# End-to-end console firmware update over USB DFU using the SDK +# (replaces the bootloader repo's flash_firmware.py / STM32CubeProgrammer): +# 1. Connect to the running console and request DFU mode. +# 2. Wait for the DFU bootloader to enumerate; print its version. +# 3. Validate the signed image locally (LIFUCrypto) and read the installed +# version over DFU — a downgrade is refused BEFORE anything is erased. +# 4. Program the image, manifest, and let the bootloader verify + boot it. +# # set PYTHONPATH=%cd%\src;%PYTHONPATH% -# python examples\test_console_dfu.py -""" -Test script to automate: -1. Connect to the device. -2. Test HVController: Turn HV on/off and check voltage. -3. Test Device functionality. -""" -print("Starting LIFU Test Script...") -interface = LIFUInterface(TX_test_mode=False) -tx_connected, hv_connected = interface.is_device_connected() -if tx_connected and hv_connected: - print("LIFU Device Fully connected.") -else: - print(f'LIFU Device NOT Fully Connected. TX: {tx_connected}, HV: {hv_connected}') +# python examples\test_console_dfu.py path\to\lifu-console-fw_signed.bin ^ +# [--keys path\to\bl-keys\console] [--force] -if not hv_connected: - print("HV Controller not connected.") - sys.exit(1) +parser = argparse.ArgumentParser(description="Console firmware update via USB DFU") +parser.add_argument("image", help="Signed SBSFU image (from LIFUCrypto or " + "the bootloader's sign_firmware.py)") +parser.add_argument("--keys", help="Keys directory for full signature " + "validation before flashing (recommended)") +parser.add_argument("--force", action="store_true", + help="Flash even if the image version is below the " + "installed one (bootloader may still reject at boot)") +parser.add_argument("--already-in-dfu", action="store_true", + help="Skip app connection; the console is already in DFU " + "(e.g. empty slot after a rejected image)") +args = parser.parse_args() -print("Ping the device") -interface.hvcontroller.ping() +def progress(written: int, total: int, label: str) -> None: + pct = 100 * written // total if total else 100 + print(f"\r {label}: {written:,}/{total:,} bytes ({pct}%)", end="", flush=True) -# Ask the user for confirmation -user_input = input("Do you want to Enter DFU Mode? (y/n): ").strip().lower() +mgr = LIFUDFUManager() +enter_dfu_fn = None -if user_input == 'y': - print("Enter DFU mode") - if interface.hvcontroller.enter_dfu(): - print("Successful.") +if not args.already_in_dfu: + print("Connecting to the console...") + interface = LIFUInterface(TX_test_mode=False) + _tx, hv_connected = interface.is_device_connected() + if not hv_connected: + print("Console not connected. If it is sitting in DFU mode " + "(e.g. after a rejected image), rerun with --already-in-dfu.") + sys.exit(1) + interface.hvcontroller.ping() + enter_dfu_fn = interface.hvcontroller.enter_dfu - print("Use stm32 cube programmer to update firmware, power cycle will put the console back into an operating state") - sys.exit(0) +try: + mgr.update_console( + args.image, + enter_dfu_fn=enter_dfu_fn, + keys_dir=args.keys, + force=args.force, + progress_callback=progress, + ) +except ValueError as e: + print(f"\nREFUSED: {e}") + sys.exit(1) -elif user_input == 'n': - print("Reset device") - if interface.hvcontroller.soft_reset(): - print("Successful.") +print("\nProgramming complete - the bootloader is now verifying the image.") +print("Waiting for the application to boot...") +time.sleep(6) -sleep(6) -interface.hvcontroller.uart.reopen_after_reset() -print("Ping the device again") -if interface.hvcontroller.ping(): - print("Test script complete.") +interface = LIFUInterface(TX_test_mode=False) +_tx, hv_connected = interface.is_device_connected() +if hv_connected and interface.hvcontroller.ping(): + print("Console is back up and responding.") else: - print("Device did not respond after reset.") + print("Console did not come back - if the bootloader rejected the image " + "(anti-rollback), the board is in DFU mode awaiting a valid image.") + sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index a38a198..02ffde6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,9 @@ dependencies = [ ] [project.optional-dependencies] +crypto = [ + "cryptography==46.0.5", +] test = [ "pytest>=6", "pytest-cov>=3", @@ -45,6 +48,7 @@ dev = [ "pytest>=6", "pytest-cov>=3", "pytest-mock", + "cryptography==46.0.5", ] ui = [ "PyQt6==6.11.0", diff --git a/src/openlifu_sdk/io/LIFUCrypto.py b/src/openlifu_sdk/io/LIFUCrypto.py new file mode 100644 index 0000000..f989432 --- /dev/null +++ b/src/openlifu_sdk/io/LIFUCrypto.py @@ -0,0 +1,617 @@ +"""SBSFU firmware image signing, validation and inspection. + +Universal implementation of the LIFU secure-bootloader image format +(``SECBOOT_ECCDSA_WITH_AES128_CBC_SHA256`` in the direct-flash / NO_LOADER +configuration) shared by the console (STM32F072) and transmitter (STM32L443) +bootloaders. A signed image is: + + [320 B header] [0xFF pad to 0x400] [firmware body, stored in clear] + +The 128-byte authenticated header region carries the metadata and the +SHA-256 of the firmware body (``FwTag``) and is signed with ECDSA-P256/ +SHA-256. At boot the bootloader verifies the header signature and compares +SHA-256(slot body) against ``FwTag``; the AES fields exist only to satisfy +the header format and are unused in this configuration. + +Key material is never bundled with the SDK: every operation that needs keys +takes an explicit keys directory containing some of:: + + ecdsa_private.pem ECDSA P-256 private key (signing) + ecdsa_public.pem ECDSA P-256 public key (validation; derived from + the private key when absent) + aes128.bin 16-byte AES-128 key (signing, header format) + +Typical use:: + + from openlifu_sdk.io.LIFUCrypto import sign_firmware_file, validate_signed_image + + hdr = sign_firmware_file( + firmware="app.bin", keys_dir="bl-keys/console", + output="app_signed.bin", version="1.0.3", + ) + report = validate_signed_image("app_signed.bin", keys_dir="bl-keys/console") + assert report.ok + +Command line:: + + python -m openlifu_sdk.io.LIFUCrypto sign --keys DIR --firmware F --output O \ + (--version V | --version-header build/.../generated/version.h) + python -m openlifu_sdk.io.LIFUCrypto verify --keys DIR SIGNED_IMAGE + python -m openlifu_sdk.io.LIFUCrypto info SIGNED_IMAGE [--keys DIR] + +Requires the optional ``cryptography`` package (``pip install +openlifu-sdk[crypto]``) for signing and signature verification; parsing and +hash checks work without it. +""" + +from __future__ import annotations + +import hashlib +import os +import re +import struct +from dataclasses import dataclass +from pathlib import Path + +from openlifu_sdk.io.exceptions import LIFUError + +try: + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives.asymmetric.utils import ( + decode_dss_signature, + encode_dss_signature, + ) + from cryptography.exceptions import InvalidSignature + _HAVE_CRYPTOGRAPHY = True +except ImportError: # pragma: no cover - depends on environment + _HAVE_CRYPTOGRAPHY = False + + +class LIFUCryptoError(LIFUError): + """Firmware signing / validation failure (bad keys, malformed image).""" + + +# --------------------------------------------------------------------------- +# SBSFU image format constants (must match the bootloaders' SECoreBin) +# --------------------------------------------------------------------------- +SFU_MAGIC = b"SFU1" +PROTOCOL_VERSION = 1 +HEADER_AUTH_LEN = 128 # bytes covered by the ECDSA signature +HEADER_SIGN_LEN = 64 # ECDSA P-256 raw R||S +HEADER_STATE_LEN = 96 # FwImageState: 3 x 32 bytes, 0xFF = VALID/new +HEADER_FP_LEN = 32 # PrevHeaderFingerprint, 0x00 on first install +HEADER_TOTAL_LEN = HEADER_AUTH_LEN + HEADER_SIGN_LEN + HEADER_STATE_LEN + HEADER_FP_LEN +IMAGE_OFFSET = 0x400 # firmware body offset = SFU_IMG_IMAGE_OFFSET +FLASH_WORD = 32 # padding granule (multiple of AES block and of + # every target's flash programming unit) + +_AUTH_STRUCT = "<4sHHIII32s32s16s28s" + +PRIVATE_KEY_FILE = "ecdsa_private.pem" +PUBLIC_KEY_FILE = "ecdsa_public.pem" +AES_KEY_FILE = "aes128.bin" + + +def _require_cryptography() -> None: + if not _HAVE_CRYPTOGRAPHY: + raise LIFUCryptoError( + "The 'cryptography' package is required for this operation. " + "Install it with: pip install openlifu-sdk[crypto]" + ) + + +# --------------------------------------------------------------------------- +# Firmware version encoding: 16-bit bitfield major[15:11] minor[10:5] patch[4:0] +# +# Ranges: major 0-31, minor 0-63, patch 0-31 (max version 31.63.31 = 0xFFFF). +# The packing is strictly monotonic with semver ordering, so the bootloader's +# anti-rollback integer comparison needs no knowledge of the scheme. +# +# NOTE: this replaces the earlier decimal MMmmpp convention (major*10000 + +# minor*100 + patch, major <= 6). Devices that latched an anti-rollback floor +# under the old scheme will reject new-scheme images until the floor is reset +# (full-chip erase), because old encodings are numerically much larger. +# --------------------------------------------------------------------------- + +VERSION_MAJOR_BITS = 5 +VERSION_MINOR_BITS = 6 +VERSION_PATCH_BITS = 5 +VERSION_MAJOR_MAX = (1 << VERSION_MAJOR_BITS) - 1 # 31 +VERSION_MINOR_MAX = (1 << VERSION_MINOR_BITS) - 1 # 63 +VERSION_PATCH_MAX = (1 << VERSION_PATCH_BITS) - 1 # 31 + + +def encode_fw_version(version: int | str) -> int: + """Encode a firmware version as the 16-bit bitfield integer. + + Accepts either an already-encoded integer (1-65535, passed through) or a + semantic version string ``"major.minor.patch"``. Encoding is + ``(major << 11) | (minor << 5) | patch`` with major 0-31, minor 0-63, + patch 0-31 - monotonic with semver ordering. Pre-release / git-describe + suffixes are ignored (``"1.8.0-rc.1-3-gabc"`` encodes as ``1.8.0``). + """ + if isinstance(version, int): + if not 1 <= version <= 0xFFFF: + raise ValueError(f"Firmware version {version} out of range 1-65535") + return version + + base = version.strip().lstrip("v").split("-")[0].split("+")[0] + parts = base.split(".") + if len(parts) != 3 or not all(p.isdigit() for p in parts): + raise ValueError(f"Invalid semantic version: {version!r} (want 'M.m.p')") + major, minor, patch = (int(p) for p in parts) + if (major > VERSION_MAJOR_MAX or minor > VERSION_MINOR_MAX + or patch > VERSION_PATCH_MAX): + raise ValueError( + f"Version {version!r} does not fit the 16-bit bitfield encoding " + f"(major 0-{VERSION_MAJOR_MAX}, minor 0-{VERSION_MINOR_MAX}, " + f"patch 0-{VERSION_PATCH_MAX})" + ) + encoded = (major << (VERSION_MINOR_BITS + VERSION_PATCH_BITS)) \ + | (minor << VERSION_PATCH_BITS) | patch + if encoded < 1: + raise ValueError("Firmware version 0.0.0 is not allowed (minimum 0.0.1)") + return encoded + + +def decode_fw_version(value: int) -> str: + """Decode a 16-bit bitfield firmware version to ``"major.minor.patch"``.""" + major = value >> (VERSION_MINOR_BITS + VERSION_PATCH_BITS) + minor = (value >> VERSION_PATCH_BITS) & VERSION_MINOR_MAX + patch = value & VERSION_PATCH_MAX + return f"{major}.{minor}.{patch}" + + +def read_fw_version_header(header_path: str | Path) -> dict[str, str]: + """Parse a CMake-generated ``version.h`` (e.g. ``build//generated/ + version.h`` in the firmware build tree). + + Returns a dict with ``FW_VERSION`` (git describe, always present) and, + when defined, ``FW_SHA`` and ``FW_BUILD_TIME``. Feed ``FW_VERSION`` + straight to :func:`encode_fw_version` / the signing functions - the + bitfield encoding keeps major.minor.patch and drops pre-release/describe + suffixes (``"1.2.6-rc.1-3-g2bfcf2a"`` encodes as ``1.2.6``); the full + string, SHA and build time remain embedded in the firmware binary itself. + + Raises: + LIFUCryptoError: File unreadable or no FW_VERSION define found. + """ + header_path = Path(header_path) + if not header_path.is_file(): + raise LIFUCryptoError(f"Version header not found: {header_path}") + text = header_path.read_text(encoding="utf-8", errors="replace") + info: dict[str, str] = {} + for key in ("FW_VERSION", "FW_SHA", "FW_BUILD_TIME"): + m = re.search(rf'#define\s+{key}\s+"([^"]*)"', text) + if m: + info[key] = m.group(1) + if "FW_VERSION" not in info: + raise LIFUCryptoError( + f'No #define FW_VERSION "..." found in {header_path}') + return info + + +# --------------------------------------------------------------------------- +# Key handling +# --------------------------------------------------------------------------- + +@dataclass +class SigningKeys: + """Key material loaded from a keys directory. + + ``private_key``/``aes_key`` are present only when the directory holds + them; ``public_key`` is loaded from ``ecdsa_public.pem`` or derived from + the private key. + """ + + keys_dir: Path + private_key: object | None = None # ec.EllipticCurvePrivateKey + public_key: object | None = None # ec.EllipticCurvePublicKey + aes_key: bytes | None = None + + @classmethod + def from_directory(cls, keys_dir: str | Path, *, + require_private: bool = False) -> "SigningKeys": + """Load keys from *keys_dir*. + + Args: + keys_dir: Directory containing the key files (see module doc). + require_private: Require the private + AES keys (signing). + + Raises: + LIFUCryptoError: Directory or required key files missing/invalid. + """ + _require_cryptography() + keys_dir = Path(keys_dir) + if not keys_dir.is_dir(): + raise LIFUCryptoError(f"Keys directory not found: {keys_dir}") + + keys = cls(keys_dir=keys_dir) + + priv_path = keys_dir / PRIVATE_KEY_FILE + if priv_path.is_file(): + try: + keys.private_key = serialization.load_pem_private_key( + priv_path.read_bytes(), password=None) + except (ValueError, TypeError) as e: + raise LIFUCryptoError(f"Cannot load {priv_path}: {e}") from e + + pub_path = keys_dir / PUBLIC_KEY_FILE + if pub_path.is_file(): + try: + keys.public_key = serialization.load_pem_public_key( + pub_path.read_bytes()) + except ValueError as e: + raise LIFUCryptoError(f"Cannot load {pub_path}: {e}") from e + elif keys.private_key is not None: + keys.public_key = keys.private_key.public_key() + + aes_path = keys_dir / AES_KEY_FILE + if aes_path.is_file(): + keys.aes_key = aes_path.read_bytes() + if len(keys.aes_key) != 16: + raise LIFUCryptoError( + f"{aes_path} must be 16 bytes, got {len(keys.aes_key)}") + + if require_private: + missing = [] + if keys.private_key is None: + missing.append(PRIVATE_KEY_FILE) + if keys.aes_key is None: + missing.append(AES_KEY_FILE) + if missing: + raise LIFUCryptoError( + f"Signing requires {', '.join(missing)} in {keys_dir}") + return keys + + +# --------------------------------------------------------------------------- +# Header parsing / metadata +# --------------------------------------------------------------------------- + +@dataclass +class FirmwareHeader: + """Parsed 320-byte SBSFU image header.""" + + magic: bytes + protocol_version: int + fw_version: int + fw_size: int + partial_fw_offset: int + partial_fw_size: int + fw_tag: bytes + partial_fw_tag: bytes + init_vector: bytes + signature: bytes + image_state: bytes + prev_fingerprint: bytes + + @classmethod + def from_bytes(cls, data: bytes) -> "FirmwareHeader": + """Parse a header from the first bytes of a signed image / slot dump. + + Raises: + LIFUCryptoError: If *data* is shorter than one header. + """ + if len(data) < HEADER_TOTAL_LEN: + raise LIFUCryptoError( + f"Image too short for an SBSFU header " + f"({len(data)} < {HEADER_TOTAL_LEN} bytes)") + (magic, proto, fw_version, fw_size, p_off, p_size, + fw_tag, p_tag, iv, _reserved) = struct.unpack_from(_AUTH_STRUCT, data, 0) + sig_off = HEADER_AUTH_LEN + state_off = sig_off + HEADER_SIGN_LEN + fp_off = state_off + HEADER_STATE_LEN + return cls( + magic=magic, protocol_version=proto, fw_version=fw_version, + fw_size=fw_size, partial_fw_offset=p_off, partial_fw_size=p_size, + fw_tag=fw_tag, partial_fw_tag=p_tag, init_vector=iv, + signature=data[sig_off:state_off], + image_state=data[state_off:fp_off], + prev_fingerprint=data[fp_off:fp_off + HEADER_FP_LEN], + ) + + @property + def fw_version_str(self) -> str: + """Firmware version decoded per the bitfield convention.""" + return decode_fw_version(self.fw_version) + + @property + def image_state_str(self) -> str: + """Human-readable FwImageState (0xFF*96 = as-signed / VALID).""" + if self.image_state == b"\xFF" * HEADER_STATE_LEN: + return "VALID (factory / as-signed)" + if self.image_state == b"\x00" * HEADER_STATE_LEN: + return "INVALIDATED" + return "MODIFIED (bootloader-managed state bytes present)" + + def describe(self) -> str: + """Multi-line human-readable summary of the header metadata.""" + return "\n".join([ + f"Magic : {self.magic!r}" + + ("" if self.magic == SFU_MAGIC else " (INVALID, expected b'SFU1')"), + f"Protocol version : {self.protocol_version}", + f"FW version : {self.fw_version} (semver {self.fw_version_str})", + f"FW size : {self.fw_size:,} bytes", + f"FW tag (SHA-256) : {self.fw_tag.hex()}", + f"Init vector : {self.init_vector.hex()} (unused at boot)", + f"Signature (R||S) : {self.signature.hex()}", + f"Image state : {self.image_state_str}", + f"Prev fingerprint : {self.prev_fingerprint.hex()}", + f"Body offset : 0x{IMAGE_OFFSET:X}", + ]) + + +def parse_signed_image(image: str | Path | bytes) -> FirmwareHeader: + """Parse the header of a signed image given as a path or raw bytes.""" + data = image if isinstance(image, bytes) else Path(image).read_bytes() + return FirmwareHeader.from_bytes(data) + + +# --------------------------------------------------------------------------- +# Signing +# --------------------------------------------------------------------------- + +def sign_firmware_bytes(firmware: bytes, keys: SigningKeys, + version: int | str) -> bytes: + """Sign a raw firmware binary; returns the complete signed image. + + Args: + firmware: Raw application binary (linked for slot base + 0x400). + keys: Keys loaded with ``require_private=True``. + version: MMmmpp integer or ``"major.minor.patch"`` string. + + Raises: + LIFUCryptoError: Missing keys. + ValueError: Bad version encoding. + """ + _require_cryptography() + if keys.private_key is None or keys.aes_key is None: + raise LIFUCryptoError( + "Signing requires the private ECDSA key and the AES key " + "(load keys with require_private=True)") + fw_version = encode_fw_version(version) + + # Pad the body to the flash-word granule; FwTag covers the padded body. + pad_len = (-len(firmware)) % FLASH_WORD + body = firmware + b"\xFF" * pad_len + fw_tag = hashlib.sha256(body).digest() + + auth_header = struct.pack( + _AUTH_STRUCT, + SFU_MAGIC, + PROTOCOL_VERSION, + fw_version, + len(body), + 0, # PartialFwOffset + 0, # PartialFwSize + fw_tag, + fw_tag, # PartialFwTag == FwTag for a full image + os.urandom(16), # InitVector: header format only, unused + b"\x00" * 28, # Reserved + ) + + der_sig = keys.private_key.sign(auth_header, ec.ECDSA(hashes.SHA256())) + r, s = decode_dss_signature(der_sig) + signature = r.to_bytes(32, "big") + s.to_bytes(32, "big") + + header = (auth_header + signature + + b"\xFF" * HEADER_STATE_LEN # FwImageState: VALID + + b"\x00" * HEADER_FP_LEN) # PrevHeaderFingerprint + return header + b"\xFF" * (IMAGE_OFFSET - HEADER_TOTAL_LEN) + body + + +def sign_firmware_file(firmware: str | Path, keys_dir: str | Path, + output: str | Path, + version: int | str) -> FirmwareHeader: + """Sign a firmware file and write the signed image. + + Args: + firmware: Path to the raw application ``.bin``. + keys_dir: Directory holding ``ecdsa_private.pem`` and ``aes128.bin``. + output: Path the signed image is written to. + version: MMmmpp integer or ``"major.minor.patch"`` string. + + Returns: + The parsed header of the signed image. + + Raises: + LIFUCryptoError: Missing/invalid keys or unreadable firmware. + """ + firmware = Path(firmware) + if not firmware.is_file(): + raise LIFUCryptoError(f"Firmware file not found: {firmware}") + keys = SigningKeys.from_directory(keys_dir, require_private=True) + signed = sign_firmware_bytes(firmware.read_bytes(), keys, version) + Path(output).write_bytes(signed) + return FirmwareHeader.from_bytes(signed) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +@dataclass +class ValidationReport: + """Result of validating a signed image. ``ok`` is the overall verdict; + the individual fields say which checks passed. ``signature_ok`` is None + when no public key was available to check it.""" + + header: FirmwareHeader + magic_ok: bool + protocol_ok: bool + size_ok: bool + fw_tag_ok: bool + partial_tag_ok: bool + pad_ok: bool + signature_ok: bool | None + trailing_bytes: int # bytes past FwSize (slot dumps: erased flash) + + @property + def structural_ok(self) -> bool: + """All checks that need no key material (magic, sizes, hashes).""" + return (self.magic_ok and self.protocol_ok and self.size_ok + and self.fw_tag_ok and self.partial_tag_ok) + + @property + def ok(self) -> bool: + return self.structural_ok and self.signature_ok is True + + @property + def verdict(self) -> str: + if self.ok: + return "VALID" + if self.structural_ok and self.signature_ok is None: + return "UNVERIFIED (structure/hashes pass; no key for signature check)" + return "NOT VALID" + + def describe(self) -> str: + def mark(v: bool | None) -> str: + return "PASS" if v else ("SKIP (no public key)" if v is None else "FAIL") + lines = [ + f"Magic 'SFU1' : {mark(self.magic_ok)}", + f"Protocol version : {mark(self.protocol_ok)}", + f"Body size vs FwSize : {mark(self.size_ok)}", + f"FwTag (SHA-256) : {mark(self.fw_tag_ok)}", + f"PartialFwTag == FwTag : {mark(self.partial_tag_ok)}", + f"Header pad (0xFF) : {mark(self.pad_ok)}", + f"ECDSA signature : {mark(self.signature_ok)}", + f"Overall : {self.verdict}", + ] + if self.trailing_bytes: + lines.insert(-1, f"Trailing bytes : {self.trailing_bytes:,} " + "(ignored; expected for slot dumps)") + return "\n".join(lines) + + +def validate_signed_image(image: str | Path | bytes, + keys_dir: str | Path | None = None) -> ValidationReport: + """Validate a signed image (or a dump of the active slot). + + Checks structure, sizes, the SHA-256 firmware tag, and - when *keys_dir* + provides a public key - the ECDSA header signature: everything the + bootloader itself checks before launching the application. + + Args: + image: Path to the signed image, or its raw bytes. A dump of the + whole slot also works; bytes past ``FwSize`` are ignored. + keys_dir: Optional keys directory for the signature check. + + Raises: + LIFUCryptoError: Image shorter than a header, or keys unreadable. + """ + data = image if isinstance(image, bytes) else Path(image).read_bytes() + header = FirmwareHeader.from_bytes(data) + + body = data[IMAGE_OFFSET:] + size_ok = len(body) >= header.fw_size + fw_body = body[:header.fw_size] + fw_tag_ok = size_ok and hashlib.sha256(fw_body).digest() == header.fw_tag + pad = data[HEADER_TOTAL_LEN:min(IMAGE_OFFSET, len(data))] + + signature_ok: bool | None = None + if keys_dir is not None: + keys = SigningKeys.from_directory(keys_dir) + if keys.public_key is None: + raise LIFUCryptoError( + f"No {PUBLIC_KEY_FILE} or {PRIVATE_KEY_FILE} in {keys_dir} " + "to verify the signature with") + r = int.from_bytes(header.signature[:32], "big") + s = int.from_bytes(header.signature[32:], "big") + try: + keys.public_key.verify(encode_dss_signature(r, s), + data[:HEADER_AUTH_LEN], + ec.ECDSA(hashes.SHA256())) + signature_ok = True + except InvalidSignature: + signature_ok = False + + return ValidationReport( + header=header, + magic_ok=header.magic == SFU_MAGIC, + protocol_ok=header.protocol_version == PROTOCOL_VERSION, + size_ok=size_ok, + fw_tag_ok=fw_tag_ok, + partial_tag_ok=header.partial_fw_tag == header.fw_tag, + pad_ok=pad == b"\xFF" * len(pad), + signature_ok=signature_ok, + trailing_bytes=max(0, len(body) - header.fw_size), + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(argv: list[str] | None = None) -> int: + import argparse + + parser = argparse.ArgumentParser( + prog="python -m openlifu_sdk.io.LIFUCrypto", + description="Sign, validate and inspect LIFU SBSFU firmware images.") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_sign = sub.add_parser("sign", help="Sign a raw firmware binary") + p_sign.add_argument("--keys", required=True, + help="Keys directory (ecdsa_private.pem + aes128.bin)") + p_sign.add_argument("--firmware", required=True, help="Raw application .bin") + p_sign.add_argument("--output", required=True, help="Signed image output path") + ver_group = p_sign.add_mutually_exclusive_group(required=True) + ver_group.add_argument("--version", + help="MMmmpp integer or 'major.minor.patch' " + "(git-describe suffixes are dropped)") + ver_group.add_argument("--version-header", + help="Path to the build's generated version.h; " + "FW_VERSION is read from it") + + p_verify = sub.add_parser("verify", help="Validate a signed image") + p_verify.add_argument("image", help="Signed image (or slot dump)") + p_verify.add_argument("--keys", required=True, + help="Keys directory (ecdsa_public.pem)") + + p_info = sub.add_parser("info", help="Show signed-image metadata") + p_info.add_argument("image", help="Signed image (or slot dump)") + p_info.add_argument("--keys", help="Optional keys directory to also " + "verify the signature") + + args = parser.parse_args(argv) + try: + if args.cmd == "sign": + if args.version_header: + info = read_fw_version_header(args.version_header) + version: int | str = info["FW_VERSION"] + print(f"Version source : {args.version_header}") + print(f" FW_VERSION : {info['FW_VERSION']}") + if "FW_SHA" in info: + print(f" FW_SHA : {info['FW_SHA']}") + if "FW_BUILD_TIME" in info: + print(f" FW_BUILD_TIME: {info['FW_BUILD_TIME']}") + print(f" Encoded : {encode_fw_version(version)} " + f"(header keeps major.minor.patch only)") + else: + version = args.version + if version.isdigit(): + version = int(version) + header = sign_firmware_file(args.firmware, args.keys, + args.output, version) + print(f"Signed image written: {args.output}") + print(header.describe()) + return 0 + if args.cmd == "verify": + report = validate_signed_image(args.image, keys_dir=args.keys) + print(report.describe()) + return 0 if report.ok else 1 + # info + report = validate_signed_image(args.image, keys_dir=args.keys) + print(report.header.describe()) + print() + print(report.describe()) + return 0 + except (LIFUCryptoError, ValueError) as e: + print(f"ERROR: {e}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/openlifu_sdk/io/LIFUDFU.py b/src/openlifu_sdk/io/LIFUDFU.py index 27a2e65..11d4b29 100644 --- a/src/openlifu_sdk/io/LIFUDFU.py +++ b/src/openlifu_sdk/io/LIFUDFU.py @@ -1,11 +1,14 @@ -"""LIFU Transmitter Firmware Update (DFU) support. +"""LIFU Firmware Update (DFU) support — transmitter and console. Provides: - :func:`stm32_crc32` — STM32-compatible CRC32 - - :func:`parse_signed_package` — parse/validate a signed firmware package - - :class:`STM32USBDFU` — USB DFU client (PyUSB, for module 0) + - :func:`parse_signed_package` — parse/validate a transmitter 'PGK1' package + - :class:`STM32USBDFU` — USB DFU client (PyUSB; erase/write/read/version) - :class:`STM32I2CDFUviaMaster`— I2C DFU via OW UART master passthrough (modules 1+) - - :class:`LIFUDFUManager` — high-level firmware update orchestration + - :class:`LIFUDFUManager` — high-level firmware update orchestration: + * transmitter modules: :meth:`LIFUDFUManager.update_module` (PGK1 packages) + * console: :meth:`LIFUDFUManager.update_console` (SBSFU signed images from + LIFUCrypto, with pre-erase validation and anti-downgrade checks) """ from __future__ import annotations @@ -129,12 +132,16 @@ class DeviceProfile: CONSOLE_PROFILE = DeviceProfile( name="console", transfer_size=1024, - version_read_len=32, + version_read_len=64, # matches DFU_VERSION_READ_LEN in usbd_dfu_if.c program_alignment_bytes=4, - app_default_address=None, + app_default_address=0x08010000, # SBSFU active slot (console memory_map.h) reset_virt_addr=0xFFFFFF08, ) +# Console SBSFU active slot: the signed image (320 B header + app @ +0x400) +# is written here; the bootloader verifies and launches it after manifest. +CONSOLE_SLOT_BASE = 0x08010000 + # OW_I2C_PASSTHRU sub-commands (must match firmware if_commands.c handler) _PASSTHRU_WRITE = 0x00 # write only _PASSTHRU_WRITE_READ = 0x01 # write then delay 5 ms then read @@ -428,6 +435,28 @@ def get_version(self) -> str: self.abort() return raw.rstrip(b"\x00").decode("ascii", errors="replace") + def read_memory(self, address: int, length: int) -> bytes: + """Read *length* bytes from target memory via DFU UPLOAD. + + The bootloader's read window applies (the console rejects reads + outside the application slot). + """ + self._recover_idle() + self._set_address(address) + self.abort() # back to dfuIDLE so UPLOAD starts at block 2 + + out = bytearray() + block = 2 + while len(out) < length: + want = min(self.transfer_size, length - len(out)) + chunk = self._ctrl_in(self.DFU_UPLOAD, block, want) + if not chunk: + break + out += chunk + block += 1 + self.abort() + return bytes(out[:length]) + def write_memory(self, address: int, data: bytes, page_erase: bool = True, progress_callback: Callable | None = None) -> None: @@ -656,7 +685,9 @@ class LIFUDFUManager: ) """ - def __init__(self, uart: "LIFUUart"): + def __init__(self, uart: "LIFUUart | None" = None): + """*uart* is only needed for the I2C passthrough paths (transmitter + modules 1+); USB-only use (console, transmitter module 0) may omit it.""" self._uart = uart # --- per-transport helpers --- @@ -709,6 +740,131 @@ def program_usb(self, package_file: str, dfu.manifest() logger.info("USB DFU: programming complete.") + # --- console (SBSFU signed image) path --- + + def get_console_installed_version(self, vid: int = 0x0483, pid: int = 0xDF11, + libusb_dll: str | None = None) -> int | None: + """Read the FwVersion of the image currently installed in the console's + active slot, or None if the slot holds no valid SBSFU header. + + The console must be in USB DFU mode. + """ + from openlifu_sdk.io import LIFUCrypto + + with STM32USBDFU(vid=vid, pid=pid, libusb_dll=libusb_dll, + device_profile=CONSOLE_PROFILE) as dfu: + hdr_bytes = dfu.read_memory(CONSOLE_SLOT_BASE, + LIFUCrypto.HEADER_TOTAL_LEN) + try: + header = LIFUCrypto.FirmwareHeader.from_bytes(hdr_bytes) + except LIFUCrypto.LIFUCryptoError: + return None + if header.magic != LIFUCrypto.SFU_MAGIC: + return None + return header.fw_version + + def program_console(self, signed_image: str, + keys_dir: str | None = None, + force: bool = False, + vid: int = 0x0483, pid: int = 0xDF11, + libusb_dll: str | None = None, + progress_callback: Callable | None = None) -> None: + """Program a console SBSFU signed image (from LIFUCrypto / the + bootloader signing tools) into the active slot via USB DFU. + + Pre-flight checks run BEFORE any flash erase, so a rejected image + leaves the installed firmware untouched (unlike naive erase-first + flashers, which strand the board with an empty slot when the + bootloader's anti-rollback refuses the new image at boot): + + 1. The image is validated locally (structure, sizes, SHA-256 tag; + plus the ECDSA signature when *keys_dir* is given). + 2. The installed slot header is read back over DFU and a version + DOWNGRADE is refused unless *force* is set. Note this compares + against the installed image only - the bootloader's persistent + anti-rollback floor is not DFU-readable and remains the final + authority at boot. + + The console must already be in USB DFU mode. + + Raises: + ValueError: Image invalid, or downgrade without *force*. + RuntimeError: DFU device/communication problems. + """ + from openlifu_sdk.io import LIFUCrypto + + image = Path(signed_image).read_bytes() + + report = LIFUCrypto.validate_signed_image(image, keys_dir=keys_dir) + if not (report.ok or (keys_dir is None and report.structural_ok)): + raise ValueError( + f"Refusing to flash invalid image {signed_image}:\n" + + report.describe() + ) + new_version = report.header.fw_version + logger.info("Console image: version %d (%s), %d bytes", + new_version, report.header.fw_version_str, len(image)) + + installed = self.get_console_installed_version( + vid=vid, pid=pid, libusb_dll=libusb_dll) + if installed is not None: + logger.info("Installed image: version %d (%s)", + installed, LIFUCrypto.decode_fw_version(installed)) + if new_version < installed and not force: + raise ValueError( + f"Downgrade refused before erase: image version {new_version} " + f"({report.header.fw_version_str}) is below the installed " + f"version {installed} " + f"({LIFUCrypto.decode_fw_version(installed)}). " + "Pass force=True to flash anyway (the bootloader's " + "anti-rollback floor may still reject it at boot, leaving " + "the slot empty)." + ) + + with STM32USBDFU(vid=vid, pid=pid, libusb_dll=libusb_dll, + device_profile=CONSOLE_PROFILE) as dfu: + dfu.write_memory(CONSOLE_SLOT_BASE, image, page_erase=True, + progress_callback=progress_callback) + logger.info("Console DFU: sending manifest (device will reset " + "and the bootloader will verify the image)...") + dfu.manifest() + logger.info("Console DFU: programming complete.") + + def update_console(self, signed_image: str, + enter_dfu_fn: Callable | None = None, + keys_dir: str | None = None, + force: bool = False, + vid: int = 0x0483, pid: int = 0xDF11, + libusb_dll: str | None = None, + dfu_wait_s: float = 2.0, + dfu_enum_timeout_s: float = 30.0, + progress_callback: Callable | None = None) -> None: + """High-level console firmware update. + + Optionally calls *enter_dfu_fn()* (e.g. ``interface.hvcontroller. + enter_dfu``) to reboot the running application into the bootloader, + waits for the DFU device to enumerate, then runs + :meth:`program_console` with its pre-erase validation and + anti-downgrade checks. + """ + if enter_dfu_fn is not None: + logger.info("Requesting console DFU mode...") + enter_dfu_fn() + if dfu_wait_s > 0: + time.sleep(dfu_wait_s) + + bl_version = self._wait_for_usb_dfu( + vid=vid, pid=pid, libusb_dll=libusb_dll, + timeout_s=dfu_enum_timeout_s, device_profile=CONSOLE_PROFILE, + ) + logger.info("Console bootloader version: %s", bl_version) + + self.program_console( + signed_image, keys_dir=keys_dir, force=force, + vid=vid, pid=pid, libusb_dll=libusb_dll, + progress_callback=progress_callback, + ) + def program_i2c(self, package_file: str, i2c_addr: int = I2C_DFU_SLAVE_ADDR, progress_callback: Callable | None = None) -> None: From 0629da771c9827c1e1d0a8c2f002e9a935e4c34b Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Mon, 20 Jul 2026 13:36:53 -0400 Subject: [PATCH 13/68] added bootloader version printout --- examples/test_console_dfu.py | 18 +++++++++++++++--- src/openlifu_sdk/io/LIFUDFU.py | 25 ++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/examples/test_console_dfu.py b/examples/test_console_dfu.py index e29f477..e07ed58 100644 --- a/examples/test_console_dfu.py +++ b/examples/test_console_dfu.py @@ -37,7 +37,6 @@ def progress(written: int, total: int, label: str) -> None: print(f"\r {label}: {written:,}/{total:,} bytes ({pct}%)", end="", flush=True) mgr = LIFUDFUManager() -enter_dfu_fn = None if not args.already_in_dfu: print("Connecting to the console...") @@ -48,12 +47,25 @@ def progress(written: int, total: int, label: str) -> None: "(e.g. after a rejected image), rerun with --already-in-dfu.") sys.exit(1) interface.hvcontroller.ping() - enter_dfu_fn = interface.hvcontroller.enter_dfu + print("Entering DFU mode...") + interface.hvcontroller.enter_dfu() + time.sleep(2) + +print("Waiting for the DFU bootloader...") +bl_version = mgr.get_console_bootloader_version() +print(f"Bootloader version : {bl_version}") + +installed = mgr.get_console_installed_version() +if installed is not None: + from openlifu_sdk.io.LIFUCrypto import decode_fw_version + print(f"Installed firmware : {installed} (semver {decode_fw_version(installed)})") +else: + print("Installed firmware : none (slot empty or invalidated)") try: mgr.update_console( args.image, - enter_dfu_fn=enter_dfu_fn, + enter_dfu_fn=None, # already in DFU at this point keys_dir=args.keys, force=args.force, progress_callback=progress, diff --git a/src/openlifu_sdk/io/LIFUDFU.py b/src/openlifu_sdk/io/LIFUDFU.py index 11d4b29..9cad032 100644 --- a/src/openlifu_sdk/io/LIFUDFU.py +++ b/src/openlifu_sdk/io/LIFUDFU.py @@ -742,6 +742,21 @@ def program_usb(self, package_file: str, # --- console (SBSFU signed image) path --- + def get_console_bootloader_version(self, vid: int = 0x0483, pid: int = 0xDF11, + libusb_dll: str | None = None, + timeout_s: float = 30.0) -> str: + """Wait for the console DFU device to enumerate (up to *timeout_s*) + and return its bootloader version string (the bootloader's git + describe, read from the DFU virtual version address). + + Raises: + RuntimeError: Device did not enumerate within the timeout. + """ + return self._wait_for_usb_dfu( + vid=vid, pid=pid, libusb_dll=libusb_dll, + timeout_s=timeout_s, device_profile=CONSOLE_PROFILE, + ) + def get_console_installed_version(self, vid: int = 0x0483, pid: int = 0xDF11, libusb_dll: str | None = None) -> int | None: """Read the FwVersion of the image currently installed in the console's @@ -838,7 +853,7 @@ def update_console(self, signed_image: str, libusb_dll: str | None = None, dfu_wait_s: float = 2.0, dfu_enum_timeout_s: float = 30.0, - progress_callback: Callable | None = None) -> None: + progress_callback: Callable | None = None) -> str: """High-level console firmware update. Optionally calls *enter_dfu_fn()* (e.g. ``interface.hvcontroller. @@ -846,6 +861,9 @@ def update_console(self, signed_image: str, waits for the DFU device to enumerate, then runs :meth:`program_console` with its pre-erase validation and anti-downgrade checks. + + Returns: + The console bootloader's version string. """ if enter_dfu_fn is not None: logger.info("Requesting console DFU mode...") @@ -853,9 +871,9 @@ def update_console(self, signed_image: str, if dfu_wait_s > 0: time.sleep(dfu_wait_s) - bl_version = self._wait_for_usb_dfu( + bl_version = self.get_console_bootloader_version( vid=vid, pid=pid, libusb_dll=libusb_dll, - timeout_s=dfu_enum_timeout_s, device_profile=CONSOLE_PROFILE, + timeout_s=dfu_enum_timeout_s, ) logger.info("Console bootloader version: %s", bl_version) @@ -864,6 +882,7 @@ def update_console(self, signed_image: str, vid=vid, pid=pid, libusb_dll=libusb_dll, progress_callback=progress_callback, ) + return bl_version def program_i2c(self, package_file: str, i2c_addr: int = I2C_DFU_SLAVE_ADDR, From 41c258101e343e035dc19a1ebac622241a77174d Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Mon, 20 Jul 2026 19:05:06 -0400 Subject: [PATCH 14/68] update migration from previous console fw versions --- examples/migrate_console_bootloader.py | 117 ++++ examples/migrate_console_legacy.py | 79 +++ src/openlifu_sdk/io/LIFUCrypto.py | 11 +- src/openlifu_sdk/io/LIFUDFU.py | 714 +++++++++++++++++++++++++ src/openlifu_sdk/io/component.py | 26 + 5 files changed, 945 insertions(+), 2 deletions(-) create mode 100644 examples/migrate_console_bootloader.py create mode 100644 examples/migrate_console_legacy.py diff --git a/examples/migrate_console_bootloader.py b/examples/migrate_console_bootloader.py new file mode 100644 index 0000000..699971a --- /dev/null +++ b/examples/migrate_console_bootloader.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import argparse +import sys +import time + +from openlifu_sdk.io.LIFUDFU import ( + LIFUDFUManager, + infer_console_bootloader_from_app_version, +) +from openlifu_sdk.io.LIFUInterface import LIFUInterface + +# Migrate a beta console unit to the secure bootloader, over USB only. +# +# All cohorts converge on one path: the running app honours the hidden +# force-STM32-ROM-DFU switch, the ROM loader can write the whole flash, and +# migrate_console_rom_dfu() installs the new secure bootloader + signed app. +# +# no-bootloader (<1.2.0) | legacy BL (1.2.0-1.2.5) | secure BL (>=1.2.6) +# --> STM32 ROM DFU --> [new secure bootloader @ 0x08000000] +# [signed app @ 0x08010000] --> power cycle +# +# WARNING: only works on unlocked beta units. After RDP/FDA lockdown the +# force switch is inert and the bootloader region cannot be erased. +# +# set PYTHONPATH=%cd%\src;%PYTHONPATH% +# python examples\migrate_console_bootloader.py ^ +# --bootloader path\to\lifu-console-bl.bin ^ +# --app path\to\lifu-console-fw_signed.bin ^ +# --keys path\to\bl-keys\console + +parser = argparse.ArgumentParser(description="Migrate a console to the secure bootloader") +src = parser.add_mutually_exclusive_group(required=True) +src.add_argument("--image", help="Combined full-flash image (bootloader + " + "signed app), e.g. openlifu-console-fw-prod_vX.bin") +src.add_argument("--bootloader", help="Raw secure bootloader .bin " + "(use with --app instead of --image)") +parser.add_argument("--app", help="Signed SBSFU app image (with --bootloader)") +parser.add_argument("--keys", help="Keys dir to fully validate the signed app") +parser.add_argument("--already-in-dfu", action="store_true", + help="Unit is already in STM32 ROM DFU; skip app connection") +parser.add_argument("--dwell", type=float, metavar="SECONDS", + help="Safety pre-check only: force ROM DFU and confirm the " + "unit stays there for SECONDS (no writes). Recommended " + "on the first legacy-bootloader unit to rule out an " + "IWDG reset mid-migration.") +args = parser.parse_args() +if args.bootloader and not args.app: + parser.error("--bootloader requires --app") + +def progress(written: int, total: int, label: str) -> None: + pct = 100 * written // total if total else 100 + print(f"\r {label}: {written:,}/{total:,} ({pct}%)", end="", flush=True) + +mgr = LIFUDFUManager() +enter_fn = None + +if not args.already_in_dfu: + print("Connecting to the console...") + interface = LIFUInterface(TX_test_mode=False) + _tx, hv = interface.is_device_connected() + if not hv: + print("Console not connected. If it is already in ROM DFU, rerun " + "with --already-in-dfu.") + sys.exit(1) + interface.hvcontroller.ping() + + # Advisory: report the cohort inferred from the running app version. + try: + ver = interface.hvcontroller.get_version() # app version string + cohort = infer_console_bootloader_from_app_version(str(ver)) + print(f"Running app version : {ver} (inferred cohort: {cohort})") + except Exception as e: + print(f"(could not read app version: {e})") + + enter_fn = interface.hvcontroller.enter_stm32_rom_dfu + +if args.dwell: + print(f"Dwell safety check: forcing ROM DFU, watching {args.dwell:.0f}s " + "for an IWDG reset (no writes)...") + stable = mgr.dwell_rom_dfu_check(enter_stm32_rom_dfu_fn=enter_fn, + seconds=args.dwell) + if stable: + print("STABLE - the unit held ROM DFU. Safe to migrate (rerun without " + "--dwell).") + sys.exit(0) + print("UNSTABLE - the unit dropped out of ROM DFU (likely IWDG). Do NOT " + "migrate this cohort over USB; use SWD/bench.") + sys.exit(1) + +print("Migrating (this forces ROM DFU, writes the bootloader + app, and " + "verifies)...") +try: + if args.image: + # Full-chip erase + write the whole combined production image. Clean + # slate (wipes legacy metadata/config/anti-rollback), recommended. + mgr.migrate_console_full_image( + combined_image=args.image, + enter_stm32_rom_dfu_fn=enter_fn, + keys_dir=args.keys, + progress_callback=progress, + ) + else: + mgr.migrate_console( + bootloader_bin=args.bootloader, + signed_app=args.app, + enter_stm32_rom_dfu_fn=enter_fn, + keys_dir=args.keys, + progress_callback=progress, + ) +except (ValueError, RuntimeError) as e: + print(f"\nMIGRATION FAILED: {e}") + sys.exit(1) + +print("\nMigration written and verified. POWER-CYCLE the console now.") +print("The secure bootloader will verify and launch the app; confirm with " + "test_console_dfu.py or a normal SDK connection.") diff --git a/examples/migrate_console_legacy.py b/examples/migrate_console_legacy.py new file mode 100644 index 0000000..8029fc1 --- /dev/null +++ b/examples/migrate_console_legacy.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import argparse +import sys +import time + +from openlifu_sdk.io.LIFUDFU import ( + LIFUDFUManager, + infer_console_bootloader_from_app_version, +) +from openlifu_sdk.io.LIFUInterface import LIFUInterface + +# Migrate a LEGACY-bootloader console (app 1.2.0-1.2.5) to the secure +# bootloader, over USB only. +# +# Legacy units can't reach the STM32 ROM DFU, so this flashes a RAM-resident +# self-updater via the legacy bootloader's own DFU; the legacy BL boots it and +# it rewrites the bootloader region from RAM, then the SDK flashes the signed +# app over the resulting secure DFU. +# +# 1.2.5 app --enter_dfu--> legacy BL DFU --write updater+meta--> reset +# --> updater replaces BL --> secure BL DFU --> flash signed app +# +# WARNING: beta/unlocked units only. The bootloader self-replacement is the one +# irreversible step - keep the unit powered throughout. +# +# set PYTHONPATH=%cd%\src;%PYTHONPATH% +# python examples\migrate_console_legacy.py ^ +# --updater path\to\updater.bin ^ +# --app path\to\lifu-console-fw_signed.bin ^ +# --keys path\to\bl-keys\console + +parser = argparse.ArgumentParser(description="Migrate a legacy-bootloader console") +parser.add_argument("--updater", required=True, + help="console-legacy-updater binary (updater.bin; embeds " + "the new secure bootloader)") +parser.add_argument("--app", required=True, help="Signed SBSFU app image") +parser.add_argument("--keys", help="Keys dir to validate the signed app") +args = parser.parse_args() + +def progress(written: int, total: int, label: str) -> None: + pct = 100 * written // total if total else 100 + print(f"\r {label}: {written:,}/{total:,} ({pct}%)", end="", flush=True) + +print("Connecting to the console...") +interface = LIFUInterface(TX_test_mode=False) +_tx, hv = interface.is_device_connected() +if not hv: + print("Console not connected.") + sys.exit(1) +interface.hvcontroller.ping() + +ver = str(interface.hvcontroller.get_version()) +cohort = infer_console_bootloader_from_app_version(ver) +print(f"Running app version : {ver} (cohort: {cohort})") +if cohort != "legacy-bl": + print(f"This script is for legacy-bootloader units (app 1.2.0-1.2.5). " + f"This unit is '{cohort}'. Use the right migration path:") + print(" no-bootloader (<1.2.0): migrate_console_bootloader.py --image ...") + print(" secure (>=1.2.6): test_console_dfu.py (normal app update)") + sys.exit(1) + +mgr = LIFUDFUManager() +print("Migrating (legacy DFU -> updater -> secure BL -> app)...") +try: + mgr.migrate_console_legacy( + updater_bin=args.updater, + signed_app=args.app, + enter_dfu_fn=interface.hvcontroller.enter_dfu, # normal DFU -> legacy BL + keys_dir=args.keys, + progress_callback=progress, + ) +except (ValueError, RuntimeError) as e: + print(f"\nMIGRATION FAILED: {e}") + sys.exit(1) + +print("\nMigration complete. POWER-CYCLE the console now.") +print("The secure bootloader will verify and launch the app.") +time.sleep(1) diff --git a/src/openlifu_sdk/io/LIFUCrypto.py b/src/openlifu_sdk/io/LIFUCrypto.py index f989432..f49d630 100644 --- a/src/openlifu_sdk/io/LIFUCrypto.py +++ b/src/openlifu_sdk/io/LIFUCrypto.py @@ -53,7 +53,14 @@ from dataclasses import dataclass from pathlib import Path -from openlifu_sdk.io.exceptions import LIFUError +# Base class for LIFUCryptoError. Guarded so this module stays usable as a +# standalone signer with only `cryptography` installed (e.g. CI signing), +# without pulling in the full SDK (numpy/pandas/...). Inside the SDK the real +# LIFUError base is used so `except LIFUError` still catches crypto errors. +try: + from openlifu_sdk.io.exceptions import LIFUError as _LIFUCryptoErrorBase +except Exception: # pragma: no cover - standalone/minimal environment + _LIFUCryptoErrorBase = Exception try: from cryptography.hazmat.primitives import hashes, serialization @@ -68,7 +75,7 @@ _HAVE_CRYPTOGRAPHY = False -class LIFUCryptoError(LIFUError): +class LIFUCryptoError(_LIFUCryptoErrorBase): """Firmware signing / validation failure (bad keys, malformed image).""" diff --git a/src/openlifu_sdk/io/LIFUDFU.py b/src/openlifu_sdk/io/LIFUDFU.py index 9cad032..dcbba23 100644 --- a/src/openlifu_sdk/io/LIFUDFU.py +++ b/src/openlifu_sdk/io/LIFUDFU.py @@ -142,6 +142,193 @@ class DeviceProfile: # is written here; the bootloader verifies and launches it after manifest. CONSOLE_SLOT_BASE = 0x08010000 +# Console flash base — where the bootloader itself lives. Writable only from +# the STM32 ROM DFU (full-flash access); the legacy and secure bootloaders +# both refuse writes to their own region. +CONSOLE_FLASH_BASE = 0x08000000 + + +def find_stm32_programmer_cli() -> str | None: + """Locate the STM32CubeProgrammer CLI (STM32_Programmer_CLI), or None. + + Checks $STM32_PROGRAMMER_CLI, PATH, and the default Windows/macOS/Linux + install locations. STM32CubeProgrammer provides a rock-solid USB-DFU + implementation used for the bootloader-replacement write, where a + pure-Python DfuSe write against the STM32 ROM loader is unreliable. + """ + import os + import shutil + + env = os.environ.get("STM32_PROGRAMMER_CLI") + if env and Path(env).is_file(): + return env + exe = "STM32_Programmer_CLI.exe" if sys.platform == "win32" else "STM32_Programmer_CLI" + onpath = shutil.which(exe) + if onpath: + return onpath + candidates = [ + r"C:\Program Files\STMicroelectronics\STM32Cube\STM32CubeProgrammer\bin", + r"C:\Program Files (x86)\STMicroelectronics\STM32Cube\STM32CubeProgrammer\bin", + "/Applications/STMicroelectronics/STM32Cube/STM32CubeProgrammer/STM32CubeProgrammer.app/Contents/MacOs/bin", + str(Path.home() / "STM32CubeProgrammer" / "bin"), + ] + for base in candidates: + p = Path(base) / exe + if p.is_file(): + return str(p) + return None + + +def split_console_flash_image(image: bytes) -> tuple[bytes, bytes]: + """Split a combined full-flash console image (bootloader + signed app, + starting at 0x08000000) into ``(bootloader_bytes, signed_app_bytes)``. + + The bootloader occupies flash up to the SBSFU slot base (offset + ``CONSOLE_SLOT_BASE - CONSOLE_FLASH_BASE`` = 0x10000); the signed app + ('SFU1' header) begins there. Trailing 0xFF fill on the bootloader + region is trimmed to the last non-blank 2 KB page. + + Raises: + ValueError: Image too small or no 'SFU1' app header at the slot. + """ + slot_off = CONSOLE_SLOT_BASE - CONSOLE_FLASH_BASE + if len(image) <= slot_off: + raise ValueError( + f"combined image is {len(image)} B; need > 0x{slot_off:X} " + "(bootloader region + signed app)") + if image[slot_off:slot_off + 4] != b"SFU1": + raise ValueError( + f"no 'SFU1' signed-app header at slot offset 0x{slot_off:X}; " + "this does not look like a combined bootloader+app image") + + bl_region = image[:slot_off] + # Trim trailing blank flash, but keep a whole 2 KB page granularity. + trimmed = bl_region.rstrip(b"\xFF") + page = 2048 + bl_len = ((len(trimmed) + page - 1) // page) * page if trimmed else 0 + return bl_region[:bl_len], image[slot_off:] + +# --------------------------------------------------------------------------- +# Legacy (non-secure) bootloader image metadata +# +# The legacy F072 bootloader (openlifu-console-bl) boots an app only if a +# metadata block at 0x08007800 authenticates it. Validation accepts EITHER +# an HMAC-SHA256 "trust tag" OR an ECDSA-P256 signature; the trust tag is +# checked first. The HMAC key is SYMMETRIC and embedded in the bootloader +# (main.c g_bl_trust_hmac_key), so a valid metadata block can be produced +# with the trust tag alone - no ECDSA private key is needed (the repo ships +# only the ECDSA public key). The signature field is part of the HMAC input +# but its contents are irrelevant when the trust tag validates, so it is +# left zero. +# --------------------------------------------------------------------------- +LEGACY_META_ADDRESS = 0x08007800 +LEGACY_APP_ADDRESS = 0x08008000 +LEGACY_APP_MAX_SIZE = 94 * 1024 +LEGACY_META_MAGIC = 0x314D4657 # 'WFM1' +LEGACY_META_VERSION = 3 +LEGACY_META_FLAG_SIG_REQUIRED = 0x0001 +LEGACY_META_KEY_ID = 1 + +# Trust HMAC key embedded in the legacy bootloader (main.c:58, key_id 1). +LEGACY_TRUST_HMAC_KEY = bytes([ + 0x17, 0xB2, 0x05, 0x19, 0x59, 0x0C, 0xFD, 0x78, + 0x10, 0x4F, 0xCE, 0x50, 0x94, 0x91, 0x34, 0x5F, + 0x36, 0xEF, 0xF0, 0x47, 0xD0, 0x32, 0x9E, 0x78, + 0xAC, 0x65, 0x06, 0x51, 0xE6, 0x35, 0xB8, 0x7E, +]) + +_LEGACY_META_HMAC_INPUT = " bytes: + """Build a legacy-bootloader metadata block (124 bytes) that authenticates + *app_bytes* via the HMAC trust tag. + + The block is written to ``LEGACY_META_ADDRESS`` (0x08007800) while the app + goes to ``fw_address`` (0x08008000). Mirrors ``build_metadata_blob`` in the + legacy repo's ``test/dfu-test.py`` (trust-tag path, zero signature). + + Raises: + ValueError: App too large for the legacy slot, or bad key length. + """ + import hashlib + import hmac + + if len(trust_key) != 32: + raise ValueError(f"trust key must be 32 bytes, got {len(trust_key)}") + if not 0 < len(app_bytes) <= LEGACY_APP_MAX_SIZE: + raise ValueError( + f"app is {len(app_bytes)} bytes; legacy slot max is {LEGACY_APP_MAX_SIZE}") + + fw_len = len(app_bytes) + fw_crc = stm32_crc32(app_bytes) + signature = b"\x00" * 64 # unused: the trust tag authenticates the image + + hmac_input = struct.pack( + _LEGACY_META_HMAC_INPUT, + LEGACY_META_MAGIC, LEGACY_META_VERSION, LEGACY_META_FLAG_SIG_REQUIRED, + fw_address, fw_len, fw_crc, key_id, signature, + ) + trust_tag = hmac.new(trust_key, hmac_input, hashlib.sha256).digest() + + meta_wo_crc = struct.pack( + _LEGACY_META_WITHOUT_CRC, + LEGACY_META_MAGIC, LEGACY_META_VERSION, LEGACY_META_FLAG_SIG_REQUIRED, + fw_address, fw_len, fw_crc, key_id, signature, trust_tag, + ) + return meta_wo_crc + struct.pack(" str: + """Infer which bootloader generation a console unit carries from the + version its RUNNING application reports (before entering any DFU mode). + + Fleet rules: + app >= 1.2.6 -> secure bootloader (``DFU_KIND_SECURE``) + 1.2.0 <= app < 1.2.6 -> legacy bootloader (``DFU_KIND_LEGACY``) + app < 1.2.0 -> no bootloader; the app boots directly and can + jump to STM32 ROM DFU (``DFU_KIND_NONE``) + + *app_version* accepts plain or git-describe semver ("1.2.6", + "1.2.6-rc.1-3-gabc", "v1.1.4"). + + Raises: + ValueError: Unparseable version string. + """ + base = app_version.strip().lstrip("v").split("-")[0].split("+")[0] + parts = base.split(".") + if len(parts) != 3 or not all(p.isdigit() for p in parts): + raise ValueError(f"Invalid app version: {app_version!r} (want 'M.m.p')") + ver = tuple(int(p) for p in parts) + + if ver >= (1, 2, 6): + return DFU_KIND_SECURE + if ver >= (1, 2, 0): + return DFU_KIND_LEGACY + return DFU_KIND_NONE + # OW_I2C_PASSTHRU sub-commands (must match firmware if_commands.c handler) _PASSTHRU_WRITE = 0x00 # write only _PASSTHRU_WRITE_READ = 0x01 # write then delay 5 ms then read @@ -435,6 +622,20 @@ def get_version(self) -> str: self.abort() return raw.rstrip(b"\x00").decode("ascii", errors="replace") + def erase_pages(self, start_addr: int, end_addr: int, + page_size: int = 2048) -> None: + """Explicitly page-erase every flash page in [start_addr, end_addr). + + Per-page DfuSe erase is used rather than the DfuSe "mass erase" + (0x41 with no address): the STM32 F0 ROM loader has been observed to + silently no-op the mass-erase command, leaving stale flash that then + corrupts writes. Per-page erase is the reliable primitive (it is what + write_memory(page_erase=True) uses, and is verified correct).""" + addr = start_addr & ~(page_size - 1) + while addr < end_addr: + self._erase_page(addr) + addr += page_size + def read_memory(self, address: int, length: int) -> bytes: """Read *length* bytes from target memory via DFU UPLOAD. @@ -519,6 +720,24 @@ def manifest(self) -> None: except Exception: pass # device disconnects during manifest — expected + def trigger_reset(self, reset_vaddr: int = 0xFFFFFF08) -> None: + """Reset the device via the bootloader's virtual reset address: a data + DNLOAD there makes the DFU media handler call NVIC_SystemReset. + + Uses the full DNLOAD path (block 2 + GETSTATUS) - the GETSTATUS is what + drives the middleware to actually perform the write (and thus the + reset); a bare control transfer without it does nothing. The device + drops off USB as it reboots, which is expected.""" + try: + self._recover_idle() + self._set_address(reset_vaddr) + try: + self._dnload(2, b"\x00\x00\x00\x00") # writes @vaddr -> reset + except Exception: + pass # device resets mid-transfer / USB drops + except Exception: + pass + # --------------------------------------------------------------------------- # I2C DFU client via OW master passthrough (modules 1+) @@ -742,6 +961,81 @@ def program_usb(self, package_file: str, # --- console (SBSFU signed image) path --- + def detect_console_dfu_kind(self, vid: int = 0x0483, pid: int = 0xDF11, + libusb_dll: str | None = None + ) -> tuple[str, str]: + """Identify which DFU environment the enumerated device is running. + + Returns ``(kind, version)`` where kind is one of ``DFU_KIND_ROM``, + ``DFU_KIND_LEGACY``, ``DFU_KIND_SECURE`` or ``DFU_KIND_UNKNOWN``, and + version is the bootloader version string when one can be determined + ("" for the ROM loader / unknown). + + The primary discriminator is the USB product string (read without a + single DFU transaction, so it is safe on all three environments). + Pre-branding secure bootloaders report the generic CubeMX string, so + for those the version is read via the DFU virtual version address. + + Raises: + RuntimeError: No DFU device enumerated, or backend unavailable. + """ + probe = STM32USBDFU(vid=vid, pid=pid, libusb_dll=libusb_dll) + dev = _usb_core.find(idVendor=vid, idProduct=pid, + backend=probe._get_backend()) + if dev is None: + raise RuntimeError( + f"No USB DFU device found (VID=0x{vid:04X}, PID=0x{pid:04X})") + try: + product = _usb_util.get_string(dev, dev.iProduct) or "" + finally: + _usb_util.dispose_resources(dev) + logger.info("DFU product string: %r", product) + + # Normalize runs of whitespace: the STM32 ROM loader reports + # "STM32 BOOTLOADER" (two spaces) on many parts. + norm = " ".join(product.split()) + if norm.startswith("STM32 BOOTLOADER"): + return (DFU_KIND_ROM, "") + if norm.startswith("LIFU BL DFU"): + return (DFU_KIND_LEGACY, norm.removeprefix("LIFU BL DFU").strip()) + if norm.startswith("OW DFU"): + return (DFU_KIND_SECURE, norm.removeprefix("OW DFU").strip()) + if norm.startswith("STM32 DownLoad Firmware Update"): + # Pre-branding secure bootloader: confirm via the version probe + try: + with STM32USBDFU(vid=vid, pid=pid, libusb_dll=libusb_dll, + device_profile=CONSOLE_PROFILE) as dfu: + return (DFU_KIND_SECURE, dfu.get_version()) + except Exception as e: + logger.warning("Secure-BL version probe failed: %s", e) + return (DFU_KIND_SECURE, "") + return (DFU_KIND_UNKNOWN, "") + + def _wait_for_dfu_kind(self, expected: str, vid: int = 0x0483, + pid: int = 0xDF11, libusb_dll: str | None = None, + timeout_s: float = 40.0) -> str: + """Poll until the console DFU environment settles on *expected* kind. + + Tolerant of the transient states across a reboot (device absent, wrong + kind briefly, USB re-enumeration): keeps trying until the expected kind + is seen or *timeout_s* elapses. Returns the version string. + """ + deadline = time.monotonic() + timeout_s + last = None + while time.monotonic() < deadline: + try: + kind, ver = self.detect_console_dfu_kind( + vid=vid, pid=pid, libusb_dll=libusb_dll) + last = (kind, ver) + if kind == expected: + return ver + except Exception as e: + last = str(e) + time.sleep(1.0) + raise RuntimeError( + f"Timed out waiting for DFU kind {expected!r} after {timeout_s:.0f}s " + f"(last seen: {last!r})") + def get_console_bootloader_version(self, vid: int = 0x0483, pid: int = 0xDF11, libusb_dll: str | None = None, timeout_s: float = 30.0) -> str: @@ -884,6 +1178,426 @@ def update_console(self, signed_image: str, ) return bl_version + def migrate_console_rom_dfu(self, bootloader_bin: str, signed_app: str, + keys_dir: str | None = None, + vid: int = 0x0483, pid: int = 0xDF11, + libusb_dll: str | None = None, + verify_rom: bool = True, + progress_callback: Callable | None = None) -> None: + """Migrate a console that is in **STM32 ROM DFU** to the secure + bootloader, in a single DFU session. + + Intended for field units with NO bootloader (app < 1.2.0): the app + jumps to the ROM system loader, which - unlike the legacy or secure + bootloaders - can write the entire flash. This writes the new secure + bootloader at 0x08000000 and the signed application at the SBSFU slot + 0x08010000, so that after a power cycle the secure bootloader verifies + and launches the app. + + Args: + bootloader_bin: Raw secure bootloader binary + (open-lifu-console-bl build//lifu-console-bl.bin). + signed_app: SBSFU signed application image (from LIFUCrypto). + keys_dir: Optional keys dir to fully verify *signed_app* first. + verify_rom: Ignored (CubeProgrammer verifies its own download); + kept for backwards compatibility. + + Raises: + ValueError: Signed app invalid, or bootloader overlaps the slot. + RuntimeError: Not in ROM DFU, CubeProgrammer missing, or a write + failure. + """ + from openlifu_sdk.io import LIFUCrypto + + del verify_rom # CubeProgrammer's -v handles verification + bl_image = Path(bootloader_bin).read_bytes() + app_image = Path(signed_app).read_bytes() + + # The app must be a valid signed SBSFU image, or the freshly written + # bootloader would reject it and strand the unit with no fallback. + report = LIFUCrypto.validate_signed_image(app_image, keys_dir=keys_dir) + if not (report.ok or (keys_dir is None and report.structural_ok)): + raise ValueError( + f"Refusing to migrate: invalid signed app:\n{report.describe()}") + + # Combine the two regions into one full-flash image so it can be + # written with STM32CubeProgrammer, whose USB-DFU implementation is + # verified byte-correct on the STM32 ROM loader (the pure-Python DfuSe + # writer is NOT reliable there). The bootloader lives at 0x08000000 + # and the signed app at the slot base 0x08010000. + slot_off = CONSOLE_SLOT_BASE - CONSOLE_FLASH_BASE + if len(bl_image) > slot_off: + raise ValueError( + f"bootloader ({len(bl_image)} B) overlaps the app slot at " + f"0x{slot_off:X}") + combined = bytearray(b"\xFF" * slot_off) + combined[:len(bl_image)] = bl_image + combined += app_image + + kind, _ = self.detect_console_dfu_kind(vid=vid, pid=pid, + libusb_dll=libusb_dll) + if kind != DFU_KIND_ROM: + raise RuntimeError( + f"Expected STM32 ROM DFU ('STM32 BOOTLOADER'), found {kind!r}. " + "This migration path is only for no-bootloader units in ROM DFU.") + + cli = find_stm32_programmer_cli() + if cli is None: + raise RuntimeError( + "STM32CubeProgrammer (STM32_Programmer_CLI) not found - it is " + "required for the bootloader-replacement write over STM32 ROM " + "DFU. Install it, add it to PATH, or set $STM32_PROGRAMMER_CLI.") + + import tempfile + with tempfile.TemporaryDirectory() as td: + img_path = Path(td) / "console_full.bin" + img_path.write_bytes(bytes(combined)) + logger.info("ROM DFU: writing bootloader (%d B) + signed app " + "(%d B) as one image @ 0x%08X", + len(bl_image), len(app_image), CONSOLE_FLASH_BASE) + self._cubeprog_write_full_image(cli, str(img_path), progress_callback) + + logger.info("ROM DFU migration complete. Power-cycle the console: the " + "secure bootloader will verify and launch the app.") + + def migrate_console(self, bootloader_bin: str, signed_app: str, + enter_stm32_rom_dfu_fn: Callable | None = None, + keys_dir: str | None = None, + vid: int = 0x0483, pid: int = 0xDF11, + libusb_dll: str | None = None, + dfu_wait_s: float = 3.0, + dfu_enum_timeout_s: float = 30.0, + progress_callback: Callable | None = None) -> None: + """End-to-end console bootloader migration for a beta unit. + + Because every application build honours the hidden force-STM32-ROM-DFU + switch, ALL cohorts converge on the same path - no interim app: + + no-bootloader (<1.2.0), legacy BL (1.2.0-1.2.5), secure BL (>=1.2.6) + --enter_stm32_rom_dfu_fn()--> STM32 ROM DFU + --migrate_console_rom_dfu()--> new secure BL + signed app + + Args: + bootloader_bin: Raw secure bootloader binary. + signed_app: SBSFU signed application image (LIFUCrypto). + enter_stm32_rom_dfu_fn: Callable that forces the running app into + STM32 ROM DFU, e.g. ``interface.hvcontroller.enter_stm32_rom_dfu``. + Omit if the unit is already in ROM DFU. + keys_dir: Optional keys dir to fully verify the signed app. + + Raises: + ValueError: Signed app invalid. + RuntimeError: ROM DFU not reached, or write/verify failure. + + NOTE: only for unlocked beta units. Once RDP/FDA lockdown is applied, + the force switch is inert and the bootloader region is not erasable. + """ + if enter_stm32_rom_dfu_fn is not None: + logger.info("Forcing the console into STM32 ROM DFU...") + enter_stm32_rom_dfu_fn() + if dfu_wait_s > 0: + time.sleep(dfu_wait_s) + + # Wait for the ROM loader to enumerate, then confirm it really is ROM + # DFU before writing the bootloader region. + self._wait_for_usb_dfu(vid=vid, pid=pid, libusb_dll=libusb_dll, + timeout_s=dfu_enum_timeout_s, + device_profile=CONSOLE_PROFILE) + kind, ver = self.detect_console_dfu_kind(vid=vid, pid=pid, + libusb_dll=libusb_dll) + logger.info("DFU environment: %s %s", kind, ver) + if kind != DFU_KIND_ROM: + raise RuntimeError( + f"Console did not enter STM32 ROM DFU (found {kind!r}). " + "The app may lack the force switch, or the unit is locked.") + + self.migrate_console_rom_dfu( + bootloader_bin, signed_app, keys_dir=keys_dir, + vid=vid, pid=pid, libusb_dll=libusb_dll, + progress_callback=progress_callback, + ) + + def migrate_console_legacy(self, updater_bin: str, signed_app: str, + enter_dfu_fn: Callable | None = None, + keys_dir: str | None = None, + vid: int = 0x0483, pid: int = 0xDF11, + libusb_dll: str | None = None, + dfu_wait_s: float = 3.0, + dfu_enum_timeout_s: float = 30.0, + updater_wait_s: float = 6.0, + progress_callback: Callable | None = None) -> None: + """Migrate a LEGACY-bootloader console (app 1.2.0–1.2.5) to the secure + bootloader, over USB only. + + Legacy units cannot reach the STM32 ROM DFU (their bootloader + intercepts the force request, and its ~5 s IWDG would kill a ROM-DFU + write). Instead the legacy bootloader's own DFU flashes a RAM-resident + self-updater into the app slot; the legacy BL boots it and it rewrites + the bootloader region from RAM. Sequence: + + 1. enter_dfu_fn() -> normal DFU (the legacy BL's own DFU). + 2. Write the legacy trust-tag metadata (built here) to 0x08007800 + and the updater to 0x08008000, over the legacy DFU; verify. + 3. Trigger a reset -> legacy BL boots the updater -> it replaces the + bootloader with the secure BL -> resets into secure DFU. + 4. Flash the signed app over the secure DFU (program_console). + + Args: + updater_bin: The console-legacy-updater binary (embeds the new + secure bootloader; links at 0x08008000). + signed_app: SBSFU signed application image. + enter_dfu_fn: Callable that reboots the running app into DFU, e.g. + ``interface.hvcontroller.enter_dfu``. + keys_dir: Optional keys dir to validate the signed app. + + Raises: + ValueError: Invalid signed app or updater. + RuntimeError: Wrong DFU environment, or a write/verify failure. + + NOTE: beta/unlocked units only. The bootloader self-replacement is the + single irreversible step — keep the unit powered throughout. + """ + from openlifu_sdk.io import LIFUCrypto + + updater = Path(updater_bin).read_bytes() + app_image = Path(signed_app).read_bytes() + report = LIFUCrypto.validate_signed_image(app_image, keys_dir=keys_dir) + if not (report.ok or (keys_dir is None and report.structural_ok)): + raise ValueError( + f"Refusing to migrate: invalid signed app:\n{report.describe()}") + metadata = build_legacy_metadata(updater) + logger.info("Legacy migration: updater %d B, metadata %d B, app v%d", + len(updater), len(metadata), report.header.fw_version) + + # --- Step 1: enter the legacy bootloader's DFU --- + if enter_dfu_fn is not None: + logger.info("Requesting DFU (legacy bootloader)...") + enter_dfu_fn() + if dfu_wait_s > 0: + time.sleep(dfu_wait_s) + self._wait_for_usb_dfu(vid=vid, pid=pid, libusb_dll=libusb_dll, + timeout_s=dfu_enum_timeout_s, + device_profile=CONSOLE_PROFILE) + kind, ver = self.detect_console_dfu_kind(vid=vid, pid=pid, + libusb_dll=libusb_dll) + if kind != DFU_KIND_LEGACY: + raise RuntimeError( + f"Expected the legacy bootloader DFU, found {kind!r} {ver!r}. " + "Use migrate_console_full_image for no-bootloader/ROM units.") + logger.info("In legacy bootloader DFU %s", ver) + + # --- Step 2: write updater + metadata over the legacy DFU, verify --- + with STM32USBDFU(vid=vid, pid=pid, libusb_dll=libusb_dll, + device_profile=CONSOLE_PROFILE) as dfu: + dfu.write_memory(LEGACY_META_ADDRESS, metadata, page_erase=True, + progress_callback=progress_callback) + dfu.write_memory(LEGACY_APP_ADDRESS, updater, page_erase=True, + progress_callback=progress_callback) + # Read-back verify (the legacy BL DFU supports UPLOAD reliably). + rb_app = dfu.read_memory(LEGACY_APP_ADDRESS, len(updater)) + rb_meta = dfu.read_memory(LEGACY_META_ADDRESS, len(metadata)) + if rb_app != updater or rb_meta != metadata: + raise RuntimeError( + "Legacy DFU write verify FAILED (updater/metadata mismatch) " + "- aborting before reset; the old app is still intact.") + logger.info("Updater + metadata written and verified.") + + # --- Step 3: reset -> legacy BL boots the updater --- + logger.info("Resetting; the updater will replace the bootloader...") + dfu.trigger_reset() + + # --- Step 4: updater runs (replaces BL, resets into secure DFU) --- + # The updater does real flash work and there are two resets + # (legacy BL -> updater -> secure BL) plus USB re-enumeration, so wait + # for the secure-BL DFU to settle rather than a single fixed delay. + logger.info("Waiting %.0fs for the updater to replace the bootloader...", + updater_wait_s) + time.sleep(updater_wait_s) + try: + ver = self._wait_for_dfu_kind(DFU_KIND_SECURE, vid=vid, pid=pid, + libusb_dll=libusb_dll, + timeout_s=dfu_enum_timeout_s) + except RuntimeError as e: + raise RuntimeError( + f"After the updater ran, the secure bootloader DFU did not " + f"appear ({e}). The bootloader replacement may have failed; " + "recover via SWD.") from e + logger.info("Secure bootloader is up (%s); flashing the signed app...", ver) + + # --- Step 5: flash the signed app over the secure DFU --- + self.program_console(signed_app, keys_dir=keys_dir, vid=vid, pid=pid, + libusb_dll=libusb_dll, + progress_callback=progress_callback) + logger.info("Legacy migration complete. Power-cycle to boot the app.") + + def migrate_console_full_image(self, combined_image: str, + enter_stm32_rom_dfu_fn: Callable | None = None, + keys_dir: str | None = None, + vid: int = 0x0483, pid: int = 0xDF11, + libusb_dll: str | None = None, + dfu_wait_s: float = 3.0, + dfu_enum_timeout_s: float = 30.0, + progress_callback: Callable | None = None) -> None: + """Migrate a console by MASS-ERASING the chip and writing the whole + combined production image (bootloader + signed app) at the flash base + in one contiguous write, via STM32 ROM DFU. + + This is the recommended path for legacy-bootloader units: the full + erase wipes the legacy metadata page, user-config page and any stale + anti-rollback state, leaving a clean slate, and the single verbatim + write of the production .bin is simpler and less error-prone than + splitting it into separate bootloader/app regions. + + Args: + combined_image: The full-flash production image beginning at + 0x08000000 (bootloader region + 'SFU1' signed app at offset + 0x10000), e.g. ``openlifu-console-fw-prod_vX.bin``. + keys_dir: Optional keys dir to fully verify the embedded signed app. + + Raises: + ValueError: Image is not a valid combined bootloader+app image. + RuntimeError: ROM DFU not reached, or a write failure. + + NOTE: beta/unlocked units only. After RDP/FDA lockdown the force + switch is inert and the flash cannot be mass-erased over DFU. + """ + from openlifu_sdk.io import LIFUCrypto + + image = Path(combined_image).read_bytes() + # Structural sanity: must be a combined image, and its embedded signed + # app must be valid (the new bootloader authenticates it at boot). + _, app_bytes = split_console_flash_image(image) + report = LIFUCrypto.validate_signed_image(app_bytes, keys_dir=keys_dir) + if not (report.ok or (keys_dir is None and report.structural_ok)): + raise ValueError( + f"Refusing to migrate: embedded app invalid:\n{report.describe()}") + logger.info("Combined image %d B; embedded app v%d (%s)", + len(image), report.header.fw_version, + report.header.fw_version_str) + + if enter_stm32_rom_dfu_fn is not None: + logger.info("Forcing the console into STM32 ROM DFU...") + enter_stm32_rom_dfu_fn() + if dfu_wait_s > 0: + time.sleep(dfu_wait_s) + + self._wait_for_usb_dfu(vid=vid, pid=pid, libusb_dll=libusb_dll, + timeout_s=dfu_enum_timeout_s, + device_profile=CONSOLE_PROFILE) + kind, _ = self.detect_console_dfu_kind(vid=vid, pid=pid, + libusb_dll=libusb_dll) + if kind != DFU_KIND_ROM: + raise RuntimeError( + f"Console did not enter STM32 ROM DFU (found {kind!r}). " + "The app may lack the force switch, or the unit is locked.") + + # The bootloader-region write over the STM32 ROM loader is done with + # STM32CubeProgrammer: its USB-DFU implementation is verified + # byte-correct on this ROM loader, whereas the pure-Python DfuSe + # write is not reliable here. A mass-erase + verified download of the + # whole image gives the clean-slate result in one step. + cli = find_stm32_programmer_cli() + if cli is None: + raise RuntimeError( + "STM32CubeProgrammer (STM32_Programmer_CLI) not found - it is " + "required for the bootloader-replacement write over STM32 ROM " + "DFU. Install it, add it to PATH, or set $STM32_PROGRAMMER_CLI.") + self._cubeprog_write_full_image(cli, combined_image, progress_callback) + + logger.info("Full-image migration complete. Power-cycle the console: " + "the secure bootloader will verify and launch the app.") + + @staticmethod + def _cubeprog_write_full_image(cli: str, image_path: str, + progress_callback: Callable | None) -> None: + """Mass-erase and write+verify a full-flash image at 0x08000000 over + USB DFU using STM32CubeProgrammer. Raises RuntimeError on failure.""" + import subprocess + + cmd = [cli, "-c", "port=USB1", "-e", "all", + "-d", str(Path(image_path)), "0x08000000", "-v"] + logger.info("Running STM32CubeProgrammer: %s", " ".join(cmd)) + proc = subprocess.run(cmd, capture_output=True, text=True) + out = (proc.stdout or "") + (proc.stderr or "") + for line in out.splitlines(): + if any(k in line for k in ("Download", "verified", "Erasing", + "Error", "erased", "complete")): + logger.info(" cubeprog: %s", line.strip()) + if proc.returncode != 0 or "Download verified successfully" not in out: + raise RuntimeError( + "STM32CubeProgrammer USB-DFU write/verify failed " + f"(rc={proc.returncode}). Output tail:\n" + + "\n".join(out.splitlines()[-15:])) + if progress_callback: + n = Path(image_path).stat().st_size + progress_callback(n, n, "CubeProgrammer USB-DFU write+verify") + + def dwell_rom_dfu_check(self, enter_stm32_rom_dfu_fn: Callable | None = None, + seconds: float = 30.0, + vid: int = 0x0483, pid: int = 0xDF11, + libusb_dll: str | None = None, + dfu_wait_s: float = 3.0) -> bool: + """Force STM32 ROM DFU (if *enter_stm32_rom_dfu_fn* is given) and then + watch that the device stays enumerated in ROM DFU for *seconds*. + + This is the pre-migration safety check for legacy-bootloader units: + the legacy bootloader arms a ~5 s IWDG before launching the app, and + if that watchdog keeps running after the app jumps to ROM DFU it would + reset the unit mid-write. A stable dwell (no disappearance, kind stays + ROM) means the migration window is safe. + + Returns: + True if the device stayed in ROM DFU for the whole window. + """ + if enter_stm32_rom_dfu_fn is not None: + logger.info("Forcing STM32 ROM DFU for dwell check...") + enter_stm32_rom_dfu_fn() + if dfu_wait_s > 0: + time.sleep(dfu_wait_s) + + probe = STM32USBDFU(vid=vid, pid=pid, libusb_dll=libusb_dll) + backend = probe._get_backend() + + deadline = time.monotonic() + seconds + checks = 0 + rom = 0 + absent = 0 + other_products: set[str] = set() + while time.monotonic() < deadline: + dev = _usb_core.find(idVendor=vid, idProduct=pid, backend=backend) + if dev is not None: + try: + product = _usb_util.get_string(dev, dev.iProduct) or "" + finally: + _usb_util.dispose_resources(dev) + if " ".join(product.split()).startswith("STM32 BOOTLOADER"): + rom += 1 + else: + other_products.add(product) + else: + absent += 1 + checks += 1 + time.sleep(1.0) + + # Interpret: settle over the first few probes, then classify. + ok = (rom > 0 and absent <= 3 and not other_products) + if other_products: + logger.warning( + "Dwell check: device is NOT in STM32 ROM DFU - it enumerated as " + "%s. The app did not reach the ROM loader (it likely lacks the " + "force-STM32 switch, or its bootloader intercepts the request). " + "This is NOT an IWDG reset; the device is stable in the wrong " + "DFU.", ", ".join(repr(p) for p in sorted(other_products))) + elif absent > 3: + logger.warning( + "Dwell check: device kept dropping off the bus (%d/%d probes " + "absent) - possible IWDG reset loop; do NOT USB-migrate this " + "unit.", absent, checks) + else: + logger.info("Dwell check: STABLE in STM32 ROM DFU " + "(%d/%d probes) - safe to migrate.", rom, checks) + return ok + def program_i2c(self, package_file: str, i2c_addr: int = I2C_DFU_SLAVE_ADDR, progress_callback: Callable | None = None) -> None: diff --git a/src/openlifu_sdk/io/component.py b/src/openlifu_sdk/io/component.py index e488907..95a4783 100644 --- a/src/openlifu_sdk/io/component.py +++ b/src/openlifu_sdk/io/component.py @@ -356,15 +356,41 @@ def soft_reset(self, module: int = 0) -> bool: self.send_checked(OW_CMD_RESET, addr=module, op="soft_reset") return True + # Hidden switch: OW_CMD_DFU reserved byte that forces the STM32 ROM + # (system-memory) DFU loader regardless of which bootloader is installed. + # Used to reflash the bootloader itself on beta units. Once units are + # locked down (RDP/FDA), the bootloader region can no longer be erased + # and this flag has no effect. + DFU_FORCE_STM32_ROM = 0x77 + def enter_dfu(self, module: int = 0, reserved: int = 0x00) -> bool: """Reboot the device into DFU mode. + With the default *reserved* the device enters whichever DFU its + installed bootloader provides (STM32 ROM for no-bootloader units, + the legacy or secure bootloader otherwise). Pass + ``reserved=OWComponent.DFU_FORCE_STM32_ROM`` (or use + :meth:`enter_stm32_rom_dfu`) to force the STM32 ROM DFU loader. + Raises: LIFUNotConnectedError, LIFUCommunicationError, LIFUDeviceError. """ self.send_checked(OW_CMD_DFU, addr=module, op="enter_dfu", reserved=reserved) return True + def enter_stm32_rom_dfu(self, module: int = 0) -> bool: + """Reboot into the STM32 ROM (system-memory) DFU loader via the hidden + force switch, regardless of the installed bootloader. + + This is the entry point for bootloader migration/replacement: the ROM + loader can write the whole flash, including the bootloader region. + Only effective on unlocked (beta) units. + + Raises: + LIFUNotConnectedError, LIFUCommunicationError, LIFUDeviceError. + """ + return self.enter_dfu(module=module, reserved=self.DFU_FORCE_STM32_ROM) + # ------------------------------------------------------------------ # User configuration helpers # ------------------------------------------------------------------ From d65b0b40578b489fa05331e28517da9e3c0d30ed Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Mon, 20 Jul 2026 19:13:39 -0400 Subject: [PATCH 15/68] update --- lizard_whitelist.csv | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lizard_whitelist.csv b/lizard_whitelist.csv index dca496b..ce4ec3e 100644 --- a/lizard_whitelist.csv +++ b/lizard_whitelist.csv @@ -17,6 +17,13 @@ send_checked # with per-step error reporting; length from step count, not nesting. update_module +# Legacy->secure bootloader migration orchestration (io/LIFUDFU.py) — the +# sequential legacy-DFU steps (write updater+metadata, reset, wait for the +# secure BL, flash the signed app) with per-step error reporting, kept inline +# to preserve the ordering; length from step count + docstring, not nesting +# (CCN 10). +migrate_console_legacy + # Trigger configuration (io/LIFUTXDevice.py) — validates and packs the full # trigger parameter set in one place to keep the JSON payload atomic. set_trigger From b1b1613134dbefeffdc21f4d75c0c6a5db4775a8 Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Mon, 20 Jul 2026 20:07:52 -0400 Subject: [PATCH 16/68] update documentation --- docs/api.md | 5 +- docs/api/LIFUCrypto.md | 110 ++++++++++++++++++++++++++++++ docs/api/LIFUDFU.md | 126 ++++++++++++++++++++++++----------- docs/api/LIFUHVController.md | 35 +++++++++- 4 files changed, 234 insertions(+), 42 deletions(-) create mode 100644 docs/api/LIFUCrypto.md diff --git a/docs/api.md b/docs/api.md index 9c5fd70..1570fe7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -10,8 +10,9 @@ Core package (entry points): I/O modules (in `openlifu_sdk.io`): - `LIFUUart` — low-level USB/serial transport wrapper and monitor. -- `LIFUHVController` — HV/console interface (power, voltage, telemetry). See [LIFUHVController API](api/LIFUHVController.md). -- `LIFUDFU` — firmware DFU helpers and managers (USB DFU, I2C DFU). See [LIFUDFU API](api/LIFUDFU.md). +- `LIFUHVController` — HV/console interface (power, voltage, telemetry, RGB LED and effects). See [LIFUHVController API](api/LIFUHVController.md). +- `LIFUDFU` — firmware DFU helpers and managers (USB/I2C DFU, console updates and bootloader migration). See [LIFUDFU API](api/LIFUDFU.md). +- `LIFUCrypto` — SBSFU firmware image signing, validation and inspection; owns the FwVersion encoding. See [LIFUCrypto API](api/LIFUCrypto.md). - `LIFUConfig`, `LIFUUserConfig` — configuration helpers for device registers and user settings. Utility modules (in `openlifu_sdk.util`): diff --git a/docs/api/LIFUCrypto.md b/docs/api/LIFUCrypto.md new file mode 100644 index 0000000..a4f4982 --- /dev/null +++ b/docs/api/LIFUCrypto.md @@ -0,0 +1,110 @@ +# `LIFUCrypto` API + +SBSFU firmware image signing, validation and inspection. This module is the +**single source of truth** for the LIFU secure-bootloader image format and the +`FwVersion` encoding — the SDK's update/migration tooling, the standalone CLI, +and the firmware-build CI all sign and verify through it. + +Image format (`SECBOOT_ECCDSA_WITH_AES128_CBC_SHA256`, NO_LOADER config): + +``` +[320 B header] [0xFF pad to 0x400] [firmware body, stored in clear] +``` + +The 128-byte authenticated header region carries the metadata and the SHA-256 +of the firmware body (`FwTag`), signed with ECDSA-P256/SHA-256. At boot the +bootloader verifies the header signature and the body hash; the AES fields +exist only to satisfy the header format and are unused in this configuration. + +Installation +- `pip install "openlifu-sdk[crypto]"` — signing/verification needs the + optional `cryptography` dependency. Parsing and hash checks work without it. +- The module also runs **standalone** (guarded imports): executing the file + directly requires only `cryptography`, not the rest of the SDK. + +FwVersion encoding (16-bit bitfield) +- `encode_fw_version(version: int | str) -> int` — accepts an already-encoded + int (1–65535) or a semver string; git-describe/pre-release suffixes are + dropped (`"1.2.6-rc.1-3-gabc"` encodes as `1.2.6`). +- `decode_fw_version(value: int) -> str` — back to `"major.minor.patch"`. +- Layout: `major[15:11] . minor[10:5] . patch[4:0]` → ranges **major 0–31, + minor 0–63, patch 0–31** (max `31.63.31` = 0xFFFF; `0.0.0` is invalid). + Strictly monotonic with semver, so the bootloader's anti-rollback integer + compare needs no knowledge of the scheme. +- **Migration note:** this replaces the earlier decimal `MMmmpp` convention + (`1.2.6` was `10206`, is now `2118`). Units with a floor latched under the + old scheme reject new-scheme images until the floor is reset (full-chip + erase / migration). +- `read_fw_version_header(header_path) -> dict` — parse a CMake-generated + `version.h` (`FW_VERSION`, `FW_SHA`, `FW_BUILD_TIME`) so builds can be + signed from their own version metadata. + +Keys +- `SigningKeys.from_directory(keys_dir, require_private=False) -> SigningKeys` + — loads from a keys directory: `ecdsa_private.pem` + `aes128.bin` (signing), + `ecdsa_public.pem` (validation; derived from the private key when absent). + Key material is never bundled with the SDK — every operation takes an + explicit keys directory. + +Signing +- `sign_firmware_file(firmware, keys_dir, output, version) -> FirmwareHeader` + — sign a raw application `.bin` (linked for slot base + 0x400) and write the + signed image; returns the parsed header. +- `sign_firmware_bytes(firmware: bytes, keys: SigningKeys, version) -> bytes` + — the in-memory core. + +Parsing and inspection +- `parse_signed_image(path | bytes) -> FirmwareHeader` — parse the 320-byte + header of a signed image (or a dump of the active slot). +- `FirmwareHeader` — dataclass with `magic`, `protocol_version`, `fw_version` + (+ `fw_version_str`), `fw_size`, `fw_tag`, `signature`, `image_state` + (+ `image_state_str`), `prev_fingerprint`; `describe()` returns a + human-readable summary. + +Validation +- `validate_signed_image(image, keys_dir=None) -> ValidationReport` — re-runs + everything the bootloader checks before launch: magic, protocol, sizes, + `FwTag` SHA-256, and (when a public key is available) the ECDSA header + signature. Accepts a full slot dump; bytes past `FwSize` are ignored. +- `ValidationReport` — per-check booleans (`magic_ok`, `fw_tag_ok`, + `signature_ok`, …), `structural_ok`, `ok`, and a three-state `verdict`: + `VALID`, `UNVERIFIED` (structure/hashes pass, no key for the signature + check), or `NOT VALID`. `describe()` prints the check table. + +Errors +- `LIFUCryptoError` — signing/validation failures (missing keys, malformed + image). Subclasses the SDK's `LIFUError` when the full SDK is installed. + +Command line + +``` +python -m openlifu_sdk.io.LIFUCrypto sign --keys DIR --firmware app.bin \ + --version 1.2.7 --output app_signed.bin +python -m openlifu_sdk.io.LIFUCrypto sign --keys DIR --firmware app.bin \ + --version-header build/Release/generated/version.h --output app_signed.bin +python -m openlifu_sdk.io.LIFUCrypto verify app_signed.bin --keys DIR # exit 0/1 +python -m openlifu_sdk.io.LIFUCrypto info app_signed.bin [--keys DIR] +``` + +`--version` takes a semver string or a raw encoded integer; +`--version-header` reads `FW_VERSION` from the firmware build's generated +`version.h` instead. `verify` is CI-friendly (exit code); `info` prints the +header metadata plus the validation table. + +Usage example + +```py +from openlifu_sdk.io.LIFUCrypto import sign_firmware_file, validate_signed_image + +hdr = sign_firmware_file( + firmware="lifu-console-fw.bin", keys_dir="bl-keys/console", + output="lifu-console-fw_signed.bin", version="1.2.7") +print(hdr.fw_version, hdr.fw_version_str) # 2119 1.2.7 + +report = validate_signed_image("lifu-console-fw_signed.bin", + keys_dir="bl-keys/console") +assert report.ok +``` + +See also: `docs/api/LIFUDFU.md` — the console update/migration paths consume +images produced here and use `validate_signed_image` as their pre-flash check. diff --git a/docs/api/LIFUDFU.md b/docs/api/LIFUDFU.md index 6d15699..6cfd10e 100644 --- a/docs/api/LIFUDFU.md +++ b/docs/api/LIFUDFU.md @@ -1,38 +1,88 @@ -# `LIFUDFU` API - -Firmware update utilities and DFU transport implementations. - -Module contents -- `stm32_crc32(data: bytes, init: int=0xFFFFFFFF) -> int` — STM32-compatible CRC32. -- `parse_signed_package(pkg: bytes) -> dict` — validate signed firmware package and return `fw_address`, `meta_address`, `fw`, `meta`. - -USB DFU (module 0) -- `STM32USBDFU(vid=..., pid=..., transfer_size=1024, timeout_ms=4000, libusb_dll=None, device_profile=None)` — minimal PyUSB-based DFU client for USB DFU programming. - - `open()`, `close()`, `get_version()`, `write_memory(address,data,...)`, `manifest()` and context-manager support. - - Requires `pyusb` and libusb backend; helper `_find_bundled_libusb_dll()` locates bundled DLL on Windows if present. - -I2C DFU via master (modules 1+) -- `STM32I2CDFUviaMaster(uart: LIFUUart, i2c_addr=0x72)` — performs I2C DFU operations routed through the USB-master using `OW_I2C_PASSTHRU` packets. - - `get_status()`, `erase_page()`, `mass_erase()`, `write_block()`, `write_memory()`, `manifest()`, `reset()`, `get_version()`. - -High-level manager -- `LIFUDFUManager(uart: LIFUUart)` — orchestrates module updates for USB (module 0) and I2C (modules 1+). - - `get_bootloader_version_usb(...)` / `get_bootloader_version_i2c(...)` - - `program_usb(package_file, ...)` — parse signed package and program module 0 via `STM32USBDFU`. - - `program_i2c(package_file, ...)` — program slave modules via `STM32I2CDFUviaMaster`. - - `update_module(module, package_file, enter_dfu_fn, ...)` — high-level flow: trigger DFU entry, wait, detect bootloader, program, and manifest. - -Notes and behaviour -- The package format is checked using `parse_signed_package` which validates header CRC and payload CRC. -- USB DFU client implements DfuSe DNLOAD/UPLOAD primitives and performs page erases before writes; `write_memory` enforces alignment and pads blocks when required. -- I2C DFU implements a passthrough protocol; block sizes are limited by `I2C_DFU_MAX_XFER_SIZE` (512 bytes). - -Usage example (from `TxDevice.update_firmware`): - -```py -from openlifu_sdk.io.LIFUDFU import LIFUDFUManager -mgr = LIFUDFUManager(uart=txdevice.uart) -mgr.update_module(module=1, package_file="fw.signed.bin", enter_dfu_fn=txdevice.enter_dfu) -``` - -See also: `docs/api/LIFUTXDevice.md` which delegates firmware work to `LIFUDFUManager`. \ No newline at end of file +# `LIFUDFU` API + +Firmware update utilities and DFU transport implementations for both the +**transmitter** (PGK1 packages, USB/I2C DFU) and the **console** (SBSFU signed +images, bootloader migration). + +Module contents +- `stm32_crc32(data: bytes, init: int=0xFFFFFFFF) -> int` — STM32-compatible CRC32. +- `parse_signed_package(pkg: bytes) -> dict` — validate a transmitter 'PGK1' firmware package and return `fw_address`, `meta_address`, `fw`, `meta`. +- `split_console_flash_image(image: bytes) -> (bootloader, signed_app)` — split a combined console full-flash image (bootloader @0x08000000 + 'SFU1' app @0x08010000). +- `build_legacy_metadata(app_bytes, ...) -> bytes` — build a legacy-console-bootloader metadata block (124 B, HMAC trust tag; no private key required). +- `find_stm32_programmer_cli() -> str | None` — locate `STM32_Programmer_CLI` (`$STM32_PROGRAMMER_CLI`, PATH, default installs). + +Console DFU environment detection +- All three console DFU environments enumerate as `0483:DF11`; the USB product string tells them apart: + - `DFU_KIND_ROM` (`"stm32-rom"`) — STM32 ROM system loader (`STM32 BOOTLOADER`); full-flash access. + - `DFU_KIND_LEGACY` (`"legacy-bl"`) — legacy bootloader (`LIFU BL DFU 0.0.x`); app region only. + - `DFU_KIND_SECURE` (`"secure-bl"`) — secure SBSFU bootloader (`OW DFU 1.x.x`, or the pre-branding CubeMX string). + - `DFU_KIND_NONE` (`"no-bootloader"`) / `DFU_KIND_UNKNOWN`. +- `infer_console_bootloader_from_app_version(app_version: str) -> str` — fleet rule from the *running* app version: `>=1.2.6` secure, `1.2.0–1.2.5` legacy, `<1.2.0` no bootloader. + +USB DFU client +- `STM32USBDFU(vid=0x0483, pid=0xDF11, transfer_size=1024, timeout_ms=4000, libusb_dll=None, device_profile=None)` — minimal PyUSB DfuSe client. + - `open()`, `close()`, context-manager support; requires `pyusb` + a libusb backend (`_find_bundled_libusb_dll()` finds the bundled Windows DLL). + - `get_version()` — bootloader version string via the virtual address `0xFFFFFF00`. + - `write_memory(address, data, page_erase=True, progress_callback=None)` — erase-then-write; reliable against the **custom** bootloaders' DFU. **Not reliable against the STM32 ROM loader** — ROM-loader writes are delegated to STM32CubeProgrammer by the manager methods. + - `read_memory(address, length) -> bytes` — DfuSe UPLOAD read (subject to the bootloader's read window). + - `erase_pages(start, end, page_size=2048)` — explicit per-page erase. + - `manifest()` — zero-length DNLOAD; device leaves DFU / launches firmware. + - `trigger_reset(reset_vaddr=0xFFFFFF08)` — reset via the custom bootloaders' virtual reset address. + - Device profiles: `TRANSMITTER_PROFILE`, `CONSOLE_PROFILE` (transfer size, version read length, program alignment). + +I2C DFU via master (transmitter modules 1+) +- `STM32I2CDFUviaMaster(uart: LIFUUart, i2c_addr=0x72)` — I2C DFU routed through the USB master via `OW_I2C_PASSTHRU`. + - `get_status()`, `erase_page()`, `mass_erase()`, `write_block()`, `write_memory()`, `manifest()`, `reset()`, `get_version()`. + +High-level manager +- `LIFUDFUManager(uart: LIFUUart | None = None)` — `uart` is only needed for the I2C passthrough paths; console/USB-only use may omit it. + +Transmitter paths +- `get_bootloader_version_usb(...)` / `get_bootloader_version_i2c(...)` +- `program_usb(package_file, ...)` — program module 0 via `STM32USBDFU` (PGK1 package). +- `program_i2c(package_file, ...)` — program slave modules via `STM32I2CDFUviaMaster`. +- `update_module(module, package_file, enter_dfu_fn, ...)` — trigger DFU entry, wait, detect bootloader, program, manifest. + +Console paths (SBSFU signed images from `LIFUCrypto`) +- `detect_console_dfu_kind(...) -> (kind, version)` — identify the enumerated DFU environment from the USB product string (no DFU transaction). +- `get_console_bootloader_version(...) -> str` — wait for enumeration and read the secure bootloader's version. +- `get_console_installed_version(...) -> int | None` — FwVersion of the image installed in the active slot (via DFU read of the header), or None. +- `program_console(signed_image, keys_dir=None, force=False, ...)` — flash a signed app over the secure bootloader's DFU. Pre-erase checks: local image validation (`LIFUCrypto`) and an installed-version comparison that **refuses a downgrade before anything is erased** (`force=True` overrides; the bootloader's anti-rollback floor remains the final authority at boot). +- `update_console(signed_image, enter_dfu_fn=None, keys_dir=None, ...) -> str` — high-level app update: enter DFU, wait, `program_console`; returns the bootloader version. +- `migrate_console_full_image(combined_image, enter_stm32_rom_dfu_fn=None, keys_dir=None, ...)` — **recommended migration**: force STM32 ROM DFU, then mass-erase + write + verify the whole combined production image via STM32CubeProgrammer (required; see `find_stm32_programmer_cli`). Full erase wipes legacy metadata/config and the anti-rollback floor. +- `migrate_console(bootloader_bin, signed_app, enter_stm32_rom_dfu_fn=None, ...)` / `migrate_console_rom_dfu(...)` — same migration from separate bootloader + signed-app files (combined internally, written via CubeProgrammer). +- `migrate_console_legacy(updater_bin, signed_app, enter_dfu_fn=None, ...)` — migration for **legacy-bootloader** units (app 1.2.0–1.2.5), which cannot reach ROM DFU: writes a RAM-resident self-updater + trust-tag metadata over the legacy DFU (read-back verified), resets, waits for the secure bootloader, then flashes the signed app. The bootloader self-replacement is the one irreversible step — keep the unit powered. +- `dwell_rom_dfu_check(enter_stm32_rom_dfu_fn=None, seconds=30, ...) -> bool` — safety pre-check: force ROM DFU and confirm the unit *stays* there (detects wrong-DFU landings and watchdog reset loops) without writing anything. + +Notes and behaviour +- Transmitter packages are validated with `parse_signed_package` (header + payload CRC). Console images are validated with `openlifu_sdk.io.LIFUCrypto` (structure, SHA-256 tag, optional ECDSA). +- Console migrations are for **unlocked (beta) units only**: after RDP/FDA lockdown the force-ROM-DFU switch is inert and the bootloader region is not erasable. +- Constants: `CONSOLE_FLASH_BASE=0x08000000`, `CONSOLE_SLOT_BASE=0x08010000`, legacy layout `LEGACY_META_ADDRESS=0x08007800`, `LEGACY_APP_ADDRESS=0x08008000`. + +Usage examples + +```py +from openlifu_sdk.io.LIFUDFU import LIFUDFUManager + +# Transmitter module update +mgr = LIFUDFUManager(uart=txdevice.uart) +mgr.update_module(module=1, package_file="fw.signed.bin", enter_dfu_fn=txdevice.enter_dfu) + +# Console app update (secure bootloader) +mgr = LIFUDFUManager() +mgr.update_console("app_signed.bin", enter_dfu_fn=interface.hvcontroller.enter_dfu, + keys_dir="bl-keys/console") + +# Console migration to the secure bootloader (no-bootloader unit) +mgr.migrate_console_full_image( + "openlifu-console-fw-production.bin", + enter_stm32_rom_dfu_fn=interface.hvcontroller.enter_stm32_rom_dfu, + keys_dir="bl-keys/console") +``` + +Runnable scripts: `examples/test_console_dfu.py` (app update), +`examples/migrate_console_bootloader.py` (no-bootloader migration, `--dwell` +pre-check), `examples/migrate_console_legacy.py` (legacy migration). + +See also: `docs/api/LIFUCrypto.md` (image signing/validation), +`docs/api/LIFUHVController.md` (DFU entry), `docs/api/LIFUTXDevice.md`. diff --git a/docs/api/LIFUHVController.md b/docs/api/LIFUHVController.md index 657b321..eacee09 100644 --- a/docs/api/LIFUHVController.md +++ b/docs/api/LIFUHVController.md @@ -29,14 +29,45 @@ Voltage and DACs Cooling and LED - `set_fan_speed(fan_id=0, fan_speed=50) -> int` — set fan 0 or 1 speed (0–100). - `get_fan_speed(fan_id=0) -> int` — read fan speed. -- `set_rgb_led(rgb_state:int) -> int` / `get_rgb_led() -> int` — control RGB state. +- `set_rgb_led(rgb_state:int) -> bool` / `get_rgb_led() -> int` — legacy enum + RGB state: 0 = OFF, 1 = RED, 2 = GREEN, 3 = BLUE. `get_rgb_led` reflects the + last basic state only; the `rgb_*` effect methods below do not change it. + +RGB color and effects (24-bit DMA color engine on the console; effects run +entirely on the device — the host just selects them. `set_rgb_led` remains +valid and cancels any running effect.) +- `set_rgb_color(r, g, b) -> bool` — static 24-bit color (each 0–255); + cancels any running effect. +- `rgb_fade_to(r, g, b, duration_ms=1000) -> bool` — fade from the current + color to the target, then hold; fading to (0,0,0) is a smooth off. +- `rgb_breathe(r, g, b, period_ms=3000) -> bool` — brightness ramps + 0 → full → 0 every period, repeating. +- `rgb_rainbow(period_ms=4000) -> bool` — full hue-wheel sweep per period, + repeating. +- `rgb_flash(r, g, b, period_ms=1000) -> bool` — 50% on/off blink; the period + is the full cycle (1000 = 0.5 s on, 0.5 s off). +- `rgb_color_cycle(colors, dwell_ms=1000) -> bool` — step through 1–8 + `(r, g, b)` tuples, each shown for `dwell_ms`, repeating. +- `rgb_effect_stop() -> bool` — cancel any effect; the LED holds its current + color (follow with `set_rgb_color` for direct control). +- All raise `ValueError` locally for out-of-range values (channels 0–255, + periods 0–65535 ms) before anything is sent to the device. Telemetry - `get_temperature1()` / `get_temperature2()` — read temperature sensors. DFU entry and reset - `soft_reset() -> bool` — soft reset the console. -- `enter_dfu() -> bool` — request DFU bootloader mode on console. +- `enter_dfu(module=0, reserved=0x00) -> bool` — reboot into DFU. With the + default `reserved` the device enters whichever DFU its installed bootloader + provides (STM32 ROM for no-bootloader units, the legacy or secure + bootloader's DFU otherwise). +- `enter_stm32_rom_dfu(module=0) -> bool` — force the STM32 ROM + (system-memory) DFU loader regardless of the installed bootloader, via the + hidden `OW_CMD_DFU reserved=0x77` switch (`OWComponent.DFU_FORCE_STM32_ROM`). + This is the entry point for bootloader migration/replacement — the ROM + loader can write the whole flash. Only effective on unlocked (beta) units; + inert after RDP/FDA lockdown. Notes - Methods often raise `ValueError` if the console is not connected. Many methods have `demo_mode` behavior when `LIFUUart.demo_mode` is set. From eafac63f0e4f1ae9190d25e73bc26b1846fb4141 Mon Sep 17 00:00:00 2001 From: George Vigelette Date: Mon, 20 Jul 2026 21:02:17 -0400 Subject: [PATCH 17/68] all console versions can be updated with the fwupdater module --- docs/api.md | 3 +- docs/api/LIFUFirmwareUpdate.md | 78 +++++ examples/migrate_console_legacy.py | 27 +- examples/update_console_firmware.py | 61 ++++ .../openlifu-console-fw-production.bin | Bin 0 -> 116960 bytes .../firmware/openlifu-console-fw-signed.bin | Bin 0 -> 51424 bytes .../firmware/openlifu-console-fw.signed.bin | Bin 46072 -> 0 bytes src/openlifu_sdk/firmware/updater.bin | Bin 0 -> 65536 bytes src/openlifu_sdk/io/LIFUDFU.py | 30 +- src/openlifu_sdk/io/LIFUFirmwareUpdate.py | 297 ++++++++++++++++++ 10 files changed, 480 insertions(+), 16 deletions(-) create mode 100644 docs/api/LIFUFirmwareUpdate.md create mode 100644 examples/update_console_firmware.py create mode 100644 src/openlifu_sdk/firmware/openlifu-console-fw-production.bin create mode 100644 src/openlifu_sdk/firmware/openlifu-console-fw-signed.bin delete mode 100644 src/openlifu_sdk/firmware/openlifu-console-fw.signed.bin create mode 100644 src/openlifu_sdk/firmware/updater.bin create mode 100644 src/openlifu_sdk/io/LIFUFirmwareUpdate.py diff --git a/docs/api.md b/docs/api.md index 1570fe7..13cdc9d 100644 --- a/docs/api.md +++ b/docs/api.md @@ -11,7 +11,8 @@ I/O modules (in `openlifu_sdk.io`): - `LIFUUart` — low-level USB/serial transport wrapper and monitor. - `LIFUHVController` — HV/console interface (power, voltage, telemetry, RGB LED and effects). See [LIFUHVController API](api/LIFUHVController.md). -- `LIFUDFU` — firmware DFU helpers and managers (USB/I2C DFU, console updates and bootloader migration). See [LIFUDFU API](api/LIFUDFU.md). +- `LIFUFirmwareUpdate` — one-call, auto-detecting console firmware update covering all three unit states (no-bootloader / legacy / secure); keyless, uses bundled images. See [LIFUFirmwareUpdate API](api/LIFUFirmwareUpdate.md). +- `LIFUDFU` — lower-level firmware DFU helpers and managers (USB/I2C DFU, per-scenario console update and bootloader migration methods). See [LIFUDFU API](api/LIFUDFU.md). - `LIFUCrypto` — SBSFU firmware image signing, validation and inspection; owns the FwVersion encoding. See [LIFUCrypto API](api/LIFUCrypto.md). - `LIFUConfig`, `LIFUUserConfig` — configuration helpers for device registers and user settings. diff --git a/docs/api/LIFUFirmwareUpdate.md b/docs/api/LIFUFirmwareUpdate.md new file mode 100644 index 0000000..5e9003b --- /dev/null +++ b/docs/api/LIFUFirmwareUpdate.md @@ -0,0 +1,78 @@ +# `LIFUFirmwareUpdate` API + +One high-level entry point for **console** firmware updates. It auto-detects +the unit's state and runs the correct path — you don't choose the scenario: + +| Unit state | App version | Path taken | +|---|---|---| +| No bootloader | < 1.2.0 | Migrate to the secure bootloader via STM32 ROM DFU (combined image) | +| Legacy bootloader | 1.2.0–1.2.5 | Migrate via the RAM-resident self-updater | +| Secure bootloader | ≥ 1.2.6 | Normal signed-app update | + +Defaults to the firmware images **bundled with the SDK** and needs **no +signing keys**: the legacy updater is authenticated by an HMAC trust tag +computed on the fly, and the secure bootloader verifies the app at boot. +An optional `keys_dir` only adds an ECDSA app-signature pre-check. + +Bundled image helpers +- `bundled_production_image() -> Path` — combined bootloader+app image + (`firmware/openlifu-console-fw-production.bin`), source for the + no-bootloader migration. +- `bundled_signed_app() -> Path` — signed console app + (`firmware/openlifu-console-fw-signed.bin`), source for the legacy migration + and secure app update. +- (The legacy RAM updater is `LIFUDFU.bundled_updater_path()`.) + +Class +- `LIFUFirmwareUpdate(hv=None, keys_dir=None, libusb_dll=None, vid=0x0483, pid=0xDF11)` + - `hv` — a connected `HVController` (e.g. `interface.hvcontroller`), used to + read the app version and trigger DFU entry. May be omitted only when the + unit is already in a DFU mode. + - `keys_dir` — optional; ECDSA-validate the signed app before flashing. +- `detect_cohort() -> (cohort, source)` — `cohort` is `"no-bootloader"` / + `"legacy-bl"` / `"secure-bl"`; `source` is `"app"` (from the running app + version) or `"dfu"` (the unit was already in a bootloader DFU). +- `update(*, production_image=None, signed_app=None, updater_bin=None, force=False, progress_callback=None) -> UpdateResult` + - Detects the state and runs the right path. All image args default to the + bundled files. `force` (secure path only) flashes even if not newer (the + bootloader's anti-rollback floor still applies at boot). + +`UpdateResult` fields: `cohort`, `action` (`"migrate-rom"` / `"migrate-legacy"` +/ `"app-update"`), `summary`, `reboot_required`. + +Behaviour notes +- If a running app is present, its version names the cohort and the updater + triggers DFU entry itself. If the unit is **already in DFU**, the DFU product + string is used and no extra DFU entry is triggered. +- Migrations are for **unlocked (beta) units only** — after RDP/FDA lockdown + the force-ROM-DFU switch is inert and the bootloader is not erasable. +- The no-bootloader/ROM path requires **STM32CubeProgrammer** (the ROM-loader + write is delegated to it). See `LIFUDFU.find_stm32_programmer_cli`. +- **Version encoding**: bundled images use the SDK's bitfield `FwVersion` + (1.2.6 = 2118). A unit whose anti-rollback floor was latched under the old + decimal scheme (1.2.6 = 10206) rejects bitfield images until the floor is + reset — which the full-erase migration does; see `LIFUCrypto`. + +Usage + +```py +from openlifu_sdk.io.LIFUInterface import LIFUInterface +from openlifu_sdk.io.LIFUFirmwareUpdate import LIFUFirmwareUpdate + +interface = LIFUInterface(TX_test_mode=False) +fw = LIFUFirmwareUpdate(hv=interface.hvcontroller) # no keys needed +result = fw.update() # auto-detect + update +print(result.summary) +if result.reboot_required: + print("Power-cycle the console.") +``` + +Runnable script: `examples/update_console_firmware.py`. + +Rebuilding the bundled `updater.bin` (for an updated secure bootloader) is +documented in `console-legacy-updater/README.md` — the updater embeds a +specific bootloader blob and is keyless. + +See also: `docs/api/LIFUDFU.md` (the underlying per-scenario methods), +`docs/api/LIFUCrypto.md` (image signing/validation), +`docs/api/LIFUHVController.md` (DFU entry). diff --git a/examples/migrate_console_legacy.py b/examples/migrate_console_legacy.py index 8029fc1..bb8bea5 100644 --- a/examples/migrate_console_legacy.py +++ b/examples/migrate_console_legacy.py @@ -24,18 +24,25 @@ # WARNING: beta/unlocked units only. The bootloader self-replacement is the one # irreversible step - keep the unit powered throughout. # +# The updater is keyless (HMAC trust tag) and ships with the SDK, so normally +# you only pass the signed app: +# # set PYTHONPATH=%cd%\src;%PYTHONPATH% -# python examples\migrate_console_legacy.py ^ -# --updater path\to\updater.bin ^ -# --app path\to\lifu-console-fw_signed.bin ^ -# --keys path\to\bl-keys\console +# python examples\migrate_console_legacy.py --app path\to\lifu-console-fw_signed.bin +# +# (--updater overrides the bundled updater; --keys is optional and only +# validates the app's signature before flashing.) parser = argparse.ArgumentParser(description="Migrate a legacy-bootloader console") -parser.add_argument("--updater", required=True, - help="console-legacy-updater binary (updater.bin; embeds " - "the new secure bootloader)") parser.add_argument("--app", required=True, help="Signed SBSFU app image") -parser.add_argument("--keys", help="Keys dir to validate the signed app") +parser.add_argument("--updater", + help="Override the updater binary. Defaults to the " + "keyless updater bundled with the SDK.") +parser.add_argument("--keys", + help="Optional keys dir to validate the signed app's " + "signature before flashing. Not required - the " + "updater is keyless and the bootloader verifies the " + "app at boot.") args = parser.parse_args() def progress(written: int, total: int, label: str) -> None: @@ -64,10 +71,10 @@ def progress(written: int, total: int, label: str) -> None: print("Migrating (legacy DFU -> updater -> secure BL -> app)...") try: mgr.migrate_console_legacy( - updater_bin=args.updater, signed_app=args.app, + updater_bin=args.updater, # None -> SDK-bundled updater enter_dfu_fn=interface.hvcontroller.enter_dfu, # normal DFU -> legacy BL - keys_dir=args.keys, + keys_dir=args.keys, # None -> app structural check only progress_callback=progress, ) except (ValueError, RuntimeError) as e: diff --git a/examples/update_console_firmware.py b/examples/update_console_firmware.py new file mode 100644 index 0000000..9907318 --- /dev/null +++ b/examples/update_console_firmware.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import argparse +import sys +import time + +from openlifu_sdk.io.LIFUFirmwareUpdate import LIFUFirmwareUpdate +from openlifu_sdk.io.LIFUInterface import LIFUInterface + +# One-command console firmware update. Detects the unit's state and runs the +# right path automatically: +# no bootloader (<1.2.0) -> migrate to the secure bootloader +# legacy bootloader (1.2.x) -> migrate via the RAM self-updater +# secure bootloader (>=1.2.6) -> normal app update +# +# Uses the firmware images bundled with the SDK and needs NO signing keys. +# +# set PYTHONPATH=%cd%\src;%PYTHONPATH% +# python examples\update_console_firmware.py # bundled images, auto-detect +# python examples\update_console_firmware.py --force # allow same/downgrade on secure + +parser = argparse.ArgumentParser(description="Update console firmware (auto-detect)") +parser.add_argument("--production", help="Override the combined bootloader+app image") +parser.add_argument("--app", help="Override the signed app image") +parser.add_argument("--keys", help="Optional keys dir to pre-validate the app signature") +parser.add_argument("--force", action="store_true", + help="Secure path only: flash even if not newer (bootloader " + "floor still applies at boot)") +args = parser.parse_args() + +def progress(w: int, t: int, label: str) -> None: + pct = 100 * w // t if t else 100 + print(f"\r {label}: {w:,}/{t:,} ({pct}%)", end="", flush=True) + +print("Connecting to the console...") +interface = LIFUInterface(TX_test_mode=False) +_tx, hv = interface.is_device_connected() +if not hv: + print("Console not connected.") + sys.exit(1) +interface.hvcontroller.ping() + +fw = LIFUFirmwareUpdate(hv=interface.hvcontroller, keys_dir=args.keys) +cohort, source = fw.detect_cohort() +print(f"Console state: {cohort} (from {source})") + +try: + result = fw.update( + production_image=args.production, + signed_app=args.app, + force=args.force, + progress_callback=progress, + ) +except (ValueError, RuntimeError) as e: + print(f"\nUPDATE FAILED: {e}") + sys.exit(1) + +print(f"\n{result.summary}") +if result.reboot_required: + print("POWER-CYCLE the console to boot the new application.") +time.sleep(1) diff --git a/src/openlifu_sdk/firmware/openlifu-console-fw-production.bin b/src/openlifu_sdk/firmware/openlifu-console-fw-production.bin new file mode 100644 index 0000000000000000000000000000000000000000..10471a453770e31913eef8b9d6fb795cdfa80b39 GIT binary patch literal 116960 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