From fb82daed8c9520b843a373950389e67ac3ab0eef Mon Sep 17 00:00:00 2001 From: JP Hutchins Date: Tue, 2 Jun 2026 18:50:45 -0700 Subject: [PATCH] feat: add boot_mode to os_management ResetWriteRequest (closes #58) Model the optional `boot_mode` field on `ResetWriteRequest` so a client can request a reboot into the bootloader (e.g. MCUboot serial recovery) rather than a normal reboot. The field is encoded into the reset command's CBOR map under the key `boot_mode` only when set, matching the existing `force` handling. Values are modeled with a `BootMode(IntEnum)` (`NORMAL = 0`, `BOOTLOADER = 1`) mirroring Zephyr's `enum BOOT_MODE_TYPES` in `include/zephyr/retention/bootmode.h`. Because the server casts the value to a `uint8_t`, the field accepts any value in `[0, 255]` (known values surface as `BootMode` members; the range guard keeps invalid messages unconstructable per the library's contract). This field was added to the SMP OS management group in Zephyr v4.2.0 (zephyrproject-rtos/zephyr#91510), gated by `CONFIG_MCUMGR_GRP_OS_RESET_BOOT_MODE` (depends on `CONFIG_RETENTION_BOOT_MODE`). Unblocks intercreate/smpclient#112 and intercreate/smpmgr#100. Co-Authored-By: Claude Opus 4.8 (1M context) --- smp/os_management.py | 33 +++++++++++++++++++++++++- tests/test_os_management.py | 46 ++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/smp/os_management.py b/smp/os_management.py index 00cf1c1..3e7e6c3 100644 --- a/smp/os_management.py +++ b/smp/os_management.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import IntEnum, unique -from typing import Any, Dict, Literal, Union +from typing import Annotated, Any, Dict, Literal, Union from pydantic import BaseModel, ConfigDict, Field @@ -30,6 +30,21 @@ class EchoWriteResponse(message.WriteResponse): """Echoed string.""" +@unique +class BootMode(IntEnum): + """Boot mode requested by the OS management reset command. + + Mirrors Zephyr's `enum BOOT_MODE_TYPES` in + `include/zephyr/retention/bootmode.h`. + """ + + NORMAL = 0 + """Default (normal) boot, to the user application.""" + + BOOTLOADER = 1 + """Bootloader boot mode, e.g. serial recovery for MCUboot.""" + + class ResetWriteRequest(message.WriteRequest): """Performs reset of system. @@ -55,6 +70,22 @@ class ResetWriteRequest(message.WriteRequest): following map may be sent to force a reset """ + boot_mode: Union[BootMode, Annotated[int, Field(ge=0, le=255)], None] = Field( + default=None, union_mode="left_to_right" + ) + """Boot mode to set via the retention boot mode module before resetting. + + A value of `BootMode.BOOTLOADER` (1) requests, for example, that an MCUboot + built with `CONFIG_BOOT_SERIAL_BOOT_MODE` enter serial recovery on the next + boot. The server casts the value to a `uint8_t`, so any value in `[0, 255]` + is accepted and passed to `bootmode_set()`; known values are surfaced as + `BootMode` members. + + Requires the server to be built with `CONFIG_MCUMGR_GRP_OS_RESET_BOOT_MODE`, + which depends on `CONFIG_RETENTION_BOOT_MODE`. Added to the SMP OS + management group in Zephyr v4.2.0 (zephyrproject-rtos/zephyr#91510). + """ + class ResetWriteResponse(message.WriteResponse): """Success response to a reset request.""" diff --git a/tests/test_os_management.py b/tests/test_os_management.py index ccb77cb..0dbfd25 100644 --- a/tests/test_os_management.py +++ b/tests/test_os_management.py @@ -5,7 +5,8 @@ from typing import Any, Dict, Type, TypeVar import cbor2 -from pydantic import BaseModel +import pytest +from pydantic import BaseModel, ValidationError from smp import header as smphdr from smp import message as smpmsg @@ -74,6 +75,49 @@ def test_ResetWriteResponse() -> None: _do_test(smpos.ResetWriteResponse, smphdr.OP.WRITE_RSP, oscmd.RESET, {}) +def test_ResetWriteRequest_boot_mode_normal() -> None: + r = _do_test(smpos.ResetWriteRequest, smphdr.OP.WRITE, oscmd.RESET, {"boot_mode": 0}) + assert r.boot_mode is smpos.BootMode.NORMAL + + +def test_ResetWriteRequest_boot_mode_bootloader() -> None: + r = _do_test(smpos.ResetWriteRequest, smphdr.OP.WRITE, oscmd.RESET, {"boot_mode": 1}) + assert r.boot_mode is smpos.BootMode.BOOTLOADER + + +def test_ResetWriteRequest_boot_mode_passes_through_unknown_int() -> None: + """A wire-valid but unrecognized boot mode stays a plain int.""" + r = _do_test(smpos.ResetWriteRequest, smphdr.OP.WRITE, oscmd.RESET, {"boot_mode": 5}) + assert r.boot_mode == 5 + assert type(r.boot_mode) is int + + +def test_ResetWriteRequest_force_and_boot_mode() -> None: + r = _do_test( + smpos.ResetWriteRequest, + smphdr.OP.WRITE, + oscmd.RESET, + {"force": 1, "boot_mode": 1}, + ) + assert r.force == 1 + assert r.boot_mode is smpos.BootMode.BOOTLOADER + + +def test_ResetWriteRequest_boot_mode_accepts_enum_member() -> None: + """Constructing with a BootMode member serializes identically to its int value.""" + from_enum = smpos.ResetWriteRequest(boot_mode=smpos.BootMode.BOOTLOADER) + from_int = smpos.ResetWriteRequest(boot_mode=1) + assert from_enum.BYTES[8:] == from_int.BYTES[8:] + assert from_enum.boot_mode is smpos.BootMode.BOOTLOADER + + +@pytest.mark.parametrize("boot_mode", [-1, 256]) +def test_ResetWriteRequest_boot_mode_rejects_out_of_range(boot_mode: int) -> None: + """boot_mode is a uint8_t on the wire; values outside [0, 255] are invalid.""" + with pytest.raises(ValidationError): + smpos.ResetWriteRequest(boot_mode=boot_mode) + + def test_TaskStatisticsReadRequest() -> None: _do_test(smpos.TaskStatisticsReadRequest, smphdr.OP.READ, oscmd.TASK_STATS, {})