mcumgr_dart
A pure-Dart client for the Simple Management Protocol (SMP) and the MCUmgr management groups used by Zephyr / MCUboot devices.
Features
- Transport-agnostic. Bring your own byte transport — BLE, serial, or TCP — by implementing a small interface. Everything above it is plain Dart.
- Byte streams included. For serial or TCP,
UartMcumgrCodecimplements Zephyr'suart_mcumgrframing, so the transport only has to move bytes. - No Flutter dependency. Runs anywhere Dart runs (Flutter apps, CLI tools,
servers). Depends only on
cborandcrypto. - All platforms: iOS · Android · macOS · Windows · Linux · Web — no native code, no platform channels.
- Batteries included for the common groups: OS (echo, datetime, reset), Image (the DFU upload/test/confirm flow), Filesystem (file download/upload/stat), Statistics (list/show counters) and Settings (read/write/delete/commit/save). Vendor groups are easy to add.
- Testable without hardware —
SmpClientruns over any transport, including an in-process loopback (see the example).
Extracted from the ProtoCentral OpenView 3 and HealthyPi Move apps, where it drives BLE firmware updates and health-data file transfer over MCUmgr.
Install
dart pub add mcumgr_dart
or add it to pubspec.yaml:
dependencies:
mcumgr_dart: ^0.1.0
Concepts
your transport (BLE / serial / TCP) ← you implement SmpTransport
│ raw bytes
▼
SmpClient ← seq matching · fragment reassembly · timeouts
│ SmpMessage (8-byte header + CBOR)
▼
OsMgmt · ImgMgmt · FsMgmt ← typed management-group facades
SmpClient speaks framed SMP messages over any SmpTransport. The management
facades (OsMgmt, ImgMgmt, FsMgmt) wrap a client and expose typed calls.
Implementing a transport
SmpClient needs a byte pipe that can send a framed request and stream inbound
notifications (which may be fragmented — the client reassembles them). A minimal
skeleton:
import 'dart:async';
import 'dart:typed_data';
import 'package:mcumgr_dart/mcumgr_dart.dart';
class MyTransport implements SmpTransport {
final _rx = StreamController<Uint8List>.broadcast();
final _states = StreamController<SmpConnectionState>.broadcast();
var _state = SmpConnectionState.disconnected;
@override
String? get deviceLabel => 'my-device';
@override
SmpConnectionState get state => _state;
@override
Stream<SmpConnectionState> get stateChanges => _states.stream;
@override
Stream<Uint8List> get notifications => _rx.stream;
@override
int? get maxWriteLength => 244; // e.g. ATT MTU − 3
@override
Future<void> connect() async {
// open the link, resolve the SMP characteristic, subscribe to notifications,
// and feed each inbound packet to `_rx.add(...)`.
}
@override
Future<void> write(Uint8List frame) async {
// write one framed SMP request (write-without-response for BLE).
}
@override
Future<void> disconnect() async {
// tear down and _state = SmpConnectionState.disconnected;
}
}
For BLE, a good pattern is universal_ble:
gate on the SMP service 8d53dc1d-1db7-4cd3-868b-8a527460aa84, subscribe to its
characteristic, and forward notifications to the notifications stream.
Byte-stream transports (serial, TCP)
On BLE the bytes on the characteristic are the SMP frame. A byte stream has no
packet boundaries, so Zephyr's uart_mcumgr transport wraps each frame in a
length prefix, a CRC-16/XMODEM and base64 lines marked 0x06 0x09 (start) or
0x04 0x14 (continuation). UartMcumgrCodec implements exactly that, leaving
the transport with nothing to do but move bytes:
final _decoder = UartMcumgrDecoder();
@override
Future<void> write(Uint8List frame) async {
for (final line in UartMcumgrCodec.encode(frame)) {
port.write(line); // SerialPort, Socket, anything byte-oriented
}
}
// Feed whatever the stream hands you — partial lines, several at once,
// interleaved console text — and forward whole frames to `notifications`.
void _onBytes(Uint8List chunk) {
for (final frame in _decoder.add(chunk)) {
_rx.add(frame);
}
}
The decoder is forgiving by design, because a device's console log usually
shares the pipe: an unmarked line is skipped, and a packet that fails its length
or CRC check is dropped rather than thrown — so one bad packet cannot wedge the
stream. Both are counted in badFrames. Accumulation is bounded by
maxPacketBytes (default 4096), so a device that opens a packet and never
finishes it cannot grow the buffer without limit. Call reset() on reconnect so
a truncated packet cannot merge into the next session. encode() emits one line
per frame by default; pass maxLineLength to split across continuation lines.
maxWriteLength still matters: it bounds ImgMgmt's upload chunks, so set it so
one framed line fits the device's receive buffer.
Usage
final client = SmpClient(myTransport);
await myTransport.connect();
// OS group — echo smoke test, set the RTC, reboot.
final os = OsMgmt(client);
print(await os.echo('hello')); // -> hello
await os.setDatetime(DateTime.now());
// await os.reset();
// Image group — DFU: upload a signed image, stage it, reboot, confirm.
final img = ImgMgmt(client, maxWriteLength: () => myTransport.maxWriteLength);
for (final slot in await img.list()) {
print('image ${slot.image} slot ${slot.slot} v${slot.version} '
'${slot.confirmed ? "confirmed" : "pending"}');
}
final sha = await img.upload(firmwareBytes,
onProgress: (sent, total) => print('$sent / $total'));
await img.test(sha); // boot the new image once
// (reboot; after it comes up healthy)
await img.confirm(sha); // make it permanent
// Filesystem group — transfer files by absolute path.
final fs = FsMgmt(client, maxWriteLength: () => myTransport.maxWriteLength);
final size = await fs.stat('/lfs/log/1');
final bytes = await fs.download('/lfs/log/1',
onProgress: (done, total) => print('$done / $total'));
await fs.upload('/lfs/config.bin', configBytes);
Statistics (group 2)
final stats = StatMgmt(client);
for (final name in await stats.list()) {
final group = await stats.show(name);
print('$name -> ${group.fields}'); // { 'frame_rx': 12, ... }
}
Settings (group 3)
final settings = SettingsMgmt(client);
final value = await settings.read('app/brightness');
if (value.truncated) print('device returned only ${value.length} bytes');
print(value.asInt()); // little-endian, width from the value
await settings.writeInt('app/brightness', 80, width: 1);
await settings.commit(); // apply at runtime
await settings.save(); // persist across reboot
A write alone does neither of the last two — that is the most common surprise with this group.
Examples
example/mcumgr_dart_example.dart— a runnable, pure-Dart loopback demo (no hardware): a fake device that answers OS-group echo. Run it withdart run example/mcumgr_dart_example.dart.example/ble_universal_ble/— a complete **Flutter- BLE** example: scan → connect → echo + image list, using a real
SmpBleTransportonuniversal_ble. (universal_bleis a dependency of that example only, never of this package.)
- BLE** example: scan → connect → echo + image list, using a real
Error handling
Non-zero MCUmgr result codes surface as SmpException (both SMP v1 top-level
rc and SMP v2 {err: {group, rc}} are normalised), with human-readable labels
for the generic MGMT_ERR_* table and the Image group. Request timeouts throw
SmpException.timeout. Transport-level failures throw SmpTransportException.
Scope
Stock Zephyr fs_mgmt exposes only per-file transfer + status — there is no
directory listing or delete in the base group, so FsMgmt is a transfer-by-
path facade, not a browser. Vendor groups can extend this.
StatMgmt is read-only because MCUmgr is: there is no command to reset a
counter, so rates must be derived by sampling show twice and differencing.
SettingsMgmt returns opaque bytes, because Zephyr's settings subsystem
carries no type information to report. SettingValue provides asInt,
asBool and asString for the usual cases and documents their assumptions
(little-endian, caller-known width); anything else, decode the bytes yourself.
Note that a device signals a truncated read by adding max_size to an
otherwise ordinary success response — SettingValue.truncated surfaces that, so
a short value is never mistaken for a whole one.
Implemented groups: OS (0), Statistics (2), Settings (3), Image (1), Filesystem (8). The Shell and Enum groups are not yet implemented — contributions welcome.
Device-side Kconfig for the newer groups: statistics needs
CONFIG_MCUMGR_GRP_STAT (which depends on CONFIG_STATS); settings needs
CONFIG_MCUMGR_GRP_SETTINGS, which requires both CONFIG_SETTINGS and
CONFIG_SETTINGS_RUNTIME — the second is easy to miss.
Additional information
- Source & issues: github.com/Protocentral/mcumgr_dart
- Why another MCUmgr package? The existing Dart option wraps native
Android/iOS/macOS libraries (mobile-only, BLE-only).
mcumgr_dartis pure Dart, so it also runs on Windows, Linux, web, CLI and servers, and lets SMP ride an existing transport instead of opening a second native BLE link. - Contributing: issues and PRs are welcome — new management groups, transport adapters, and test coverage especially.
- Protocol reference: the SMP header and CBOR payload layout follow the MCUmgr / SMP specification.
License
MIT © ProtoCentral
Libraries
- mcumgr_dart
- A pure-Dart client for the Simple Management Protocol (SMP) and the MCUmgr management groups used by Zephyr / MCUboot devices.