Skip to main content

smpgroup

Declarative custom MCUmgr management groups for Zephyr devices.

Describe your device's custom SMP group once, as data. Then generate the host-side request/response classes from it — and check that the description still matches the firmware that implements it.

pip install smpgroup           # spec + drift checker, stdlib only
pip install 'smpgroup[smp]'    # + class generation (pulls in smpclient)

Apache-2.0. Works with smpclient.


The problem

Zephyr lets you add a custom MCUmgr management group (id ≥ 64) for device-specific commands. On the host side you then hand-write a pydantic request/response pair per command, because that is what smpclient consumes.

That works until you have more than one host tool. A test harness, a CLI, an updater and a provisioning script each end up with their own copy of the same schemas. And because smp's response models are pydantic with extra="forbid", a response class must declare every key the device sends — so the day someone adds a field to a reply in firmware, every copy that wasn't updated starts failing with an opaque validation error, at runtime, usually on someone else's bench.

The schemas are data. They should be written once, as data.

Usage

1. Describe the group

from smpgroup import Group, Command, Field, Op, Status, T

R, W = Op.READ, Op.WRITE

MY_GROUP = Group(
    group_id=64,
    name="acme",
    schema_version=1,
    commands=(
        Command(0x0001, "device_info", (R,), response=(
            Field("sn", T.TSTR, "serial number"),
            Field("up", T.UINT, "uptime, seconds"),
        )),
        Command(0x0002, "set_led", (W,),
                meta={"privileged": True},
                request=(Field("on", T.BOOL),),
                response=(Field("ok", T.BOOL),)),
        # both ops, different schemas
        Command(0x0003, "mode", (R, W),
                response=(Field("armed", T.BOOL),),
                write_request=(Field("on", T.BOOL),),
                write_response=(Field("armed", T.BOOL),)),
        # declared in the firmware header but never dispatched:
        Command(0x0004, "reserved", (), status=Status.UNREACHABLE),
    ),
    errors={
        256: ("NOT_READY", "a precondition is missing"),
        257: ("HW_FAULT", "hardware failure"),
    },
)

T.MAP fields take nested=(...) for maps whose keys you know; nested maps follow the same unknown-key policy as the reply that contains them.

2. Generate the wire classes

from smpgroup.build import build

g = build(MY_GROUP)

async with SMPClient(SMPSerialTransport(), port) as client:
    info = await client.request(g.device_info())
    print(info.sn, info.up)

    await client.request(g.set_led(on=True))

    await client.request(g.mode())            # the read op
    await client.request(g.mode_write(on=1))  # the write op

A command declaring both ops is exposed as g.<name> (read) and g.<name>_write. Commands marked UNREACHABLE generate nothing — you cannot accidentally ship a verb the device will only ever answer with ENOTSUP.

Unknown reply keys are dropped by default. A host tool and the firmware it talks to ship on their own schedules, so a device newer than your spec will send keys the spec does not declare. build(MY_GROUP) ignores them, so the reply still parses; build(MY_GROUP, strict=True) rejects them, for a test bench that should notice the spec falling behind. Requests are always strict.

The SMP error keys are the exception: an undeclared top-level rc or err still fails validation. smpclient tries a reply as the success class before the error classes, so a tolerant success class would otherwise accept an error reply to any command that has no reply fields. Keeping the spec complete is the drift checker's job (below), not the runtime's.

3. Check the spec against the firmware

This is the part you will not find elsewhere. Point it at the Zephyr sources that implement the group; it re-derives the facts and reports disagreement:

from smpgroup.drift import Sources, check

SRC = Sources.zephyr(
    "firmware/src/mgmt",
    header="acme_mgmt.h",        # declares ACME_CMD_* and the error enum
    dispatch="acme_mgmt.c",      # the [ACME_CMD_*] = {...} handler table
    id_prefix="ACME_CMD_",
    err_prefix="ACME_ERR_",
    err_enum="enum acme_err",
)

report = check(MY_GROUP, SRC)
assert report.ok, str(report)

It catches:

  • a command id that changed
  • a command the firmware declares but your spec doesn't have (or vice versa)
  • a command your spec calls live that the firmware never dispatches
  • an error code that moved, was renamed, or is missing from either side
  • a request or response key missing from that command's handler — a renamed or removed field, or one the spec gave to the wrong command

Drop it in a pytest and your CI now fails on protocol drift instead of your users discovering it:

def test_no_drift():
    report = check(MY_GROUP, SRC)
    assert report.ok, str(report)

Keys are checked per command. The dispatch table names each op's handler, and a key must appear in that function, in a function it calls within the sources, or at file scope in its file (a static key table). A key that only another command emits does not count — a global search would have passed it. The checker parses rather than compiles, so when it cannot find a handler (a macro, or code outside the given sources) it falls back to searching every handler.

Design notes

Stdlib-only where it counts. smpgroup.spec and smpgroup.drift import nothing outside the standard library, so a protocol definition and a firmware consistency check run in any CI job without a device stack. Only smpgroup.build needs smpclient, and import smpgroup never pulls it in.

The spec is the artifact. It is plain frozen dataclasses — diff it in review, generate documentation from it, drive a CLI from it, or hand it to another team as the contract. Group.describe() prints a readable summary.

meta is yours. Commands carry a free-form meta mapping for project-specific facts the library shouldn't know about — access gates, build flavors, feature flags — with Group.tagged("key") to query it.

Validation at construction. Duplicate command ids, a group id below the user range, an unreachable command that declares ops, a write schema on a read-only command: all rejected when the Group is built, not at runtime.

Status

0.1.0, alpha. Extracted from the HealthyPi 6 firmware project, where it describes a 40-id management group across four host tools and is checked against the firmware on every CI run.

The API may change before 1.0. Issues and PRs welcome.

Metadata

Release files for smpgroup 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for smpgroup 0.1.0
File Size Uploaded Provenance Actions
smpgroup-0.1.0.tar.gz 22.3 kB With provenance Details for smpgroup-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for smpgroup 0.1.0
File Interpreter ABI Platform Actions
smpgroup-0.1.0-py3-none-any.whl Python 3 none any Details for smpgroup-0.1.0-py3-none-any.whl

Total release size: 39.2 kB

Release files / smpgroup-0.1.0.tar.gz

Download URL smpgroup-0.1.0.tar.gz
Size 22.3 kB
Tags Source
SHA-256 checksum
How to use checksums
d27bfbb8a73eb471ae51662ac344c34f8157705814588937a77d2451b6f874a1
BLAKE2b-256 checksum
How to use checksums
34e22c70bfb2f2a5a0bda74d84fe61472233ac46ad6786585baab54c61474717
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Release files / smpgroup-0.1.0-py3-none-any.whl

Download URL smpgroup-0.1.0-py3-none-any.whl
Size 16.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
237a58be5b9e5105200e13a72f8c4e270730d749259bf4ad357df7b8e5c61525
BLAKE2b-256 checksum
How to use checksums
b2dcb0bb771bbd5b451610db12fec80b875e6966b1b3116f72fc40686bdac6fc
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

Security for smpgroup 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for smpgroup-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page