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)
| 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)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| 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 logRelease 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 logSecurity 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.
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 logA 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.