mat is a CLI for controlling Matter devices. It drives a from-scratch native
Matter controller (crate mat-controller, in this workspace) in-process and
returns pure structured JSON, normalized to mat's own schema. (chip-tool
was the backend through Phase 5 M8c-2; as of 0.22.0 it is fully retired — see
Backend.)
- stdout = one JSON object per command. No human decoration.
- diagnostics go to stderr as structured logs (
tracing). - it holds no state except the credential KVS (the process is one-shot).
For the design background, the mat / matd split, and what mat does and
does not do, see ARCHITECTURE.md.
Everything documented below is implemented on the native backend: discover /
commission (on-network and BLE+Thread), first-fabric bootstrap (fabric init),
state operations (read / write / invoke / describe / on / off), multi-admin
share (open-window), groupcast (group provision / group invoke), the
resident daemon matd (warm CASE sessions, mat --matd), diagnostics
(diag thread / diag node), and matd's resident wildcard Subscribe with
mat listen streaming device-originated events (matd-only, no direct
fallback). It passes the fake-connection / binary integration tests, and
real-device E2E (Phase 5 gate 1) has confirmed the full op sweep runs
natively with no fallback; mat listen's real-device E2E is pending a
separate deploy session. Group delivery is unacknowledged multicast by
design, so per-device actuation cannot be confirmed from the controller side
(see Groupcast below).
The development roadmap and the Phase 5 native-backend record live in ARCHITECTURE.md.
- Rust (stable) and Task to build. No external Matter controller is needed — the backend is native and pure Rust.
- Matter uses mDNS / IPv6 multicast, so on a real network the host must be able
to send and receive these.
matauto-detects the network interface (override withMAT_IFACE; see Backend). - BLE commissioning (BLE+Thread) is an opt-in
blecargo feature (pulls inlibdbus); the default build and localtask checkdo not need it. Deploy builds enable it — see Backend.
task build # release build -> target/release/{mat,matd}
task install # install both binaries into ~/.cargo/bin# Discover commissionable / commissioned nodes (ledger only, fast)
mat discover
# Also probe live reachability of commissioned nodes via mDNS
mat discover --probe
# Join a fabric (first commission OR multi-admin join, both supported)
# All values here are dummy (RFC 5737 192.0.2.0/24)
mat commission --target 192.0.2.10 --setup-code "MT:Y.K9042C00KA0648G00" --node 5commission picks how to reach the device. mDNS finding a record only proves the
record exists — a Thread device's SRP registration outlives the device's
reachability — so auto keeps BLE as a fallback:
--transport |
QR payload (MT:) |
manual code |
|---|---|---|
auto (default) |
mDNS hit → on-network, then BLE if PASE times out; miss → BLE | mDNS hit → on-network; miss → unreachable |
on-network |
mDNS only; never falls back to BLE | same |
ble |
skips mDNS entirely | rejected (exit 2) |
The fallback fires only when PASE exhausts its MRP retry budget (or the device disappears before PASE) — i.e. before any failsafe is armed, so nothing was ever established on the device. A failure after PASE (attestation, NOC, CASE) stops immediately: the device holds partial state under its failsafe and must not be re-driven automatically.
After an mDNS hit, BLE is added to the plan only when it can actually run — a
build with the ble cargo feature and a --thread-dataset. Without both,
auto behaves exactly like on-network on a hit (an INFO line on stderr says
so), instead of replacing the real on-network error with a BLE one. After an mDNS
miss BLE is always attempted, so the error names whichever piece is missing.
A manual code carries only a 4-bit short discriminator, which cannot drive the 12-bit BLE scan; use the QR payload for BLE.
# force BLE (skip mDNS entirely)
mat commission --target thread --setup-code "MT:Y.K9042C00KA0648G00" --transport blediscover output:
{
"timestamp": "2026-06-06T12:34:56+09:00",
"devices": [
{ "state": "commissionable", "hostname": "B827EBA8C9F0", "addresses": ["192.0.2.10"], "port": 5540, "discriminator": 3840, "vendor_id": 65521, "product_id": 32769 },
{ "state": "commissioned", "node_id": 5, "address": "192.0.2.10", "commissioned_at": "2026-06-06T12:00:00+09:00" }
]
}With --probe, each commissioned node is checked against a live mDNS resolve
(a native targeted _matter._tcp lookup per node, run concurrently) and
annotated:
reachable: true— advertising now;addressis the live-resolved value (may differ from the ledger).reachable: false— not advertising;addressis the last-known ledger value withstale: true.reachable: null— the mDNS probe could not run (e.g. an interface I/O error); reachability is unknown. A diagnostic is logged to stderr.
{
"timestamp": "2026-06-06T12:34:56+09:00",
"devices": [
{ "state": "commissioned", "node_id": 5, "address": "192.0.2.99", "commissioned_at": "2026-06-06T12:00:00+09:00", "reachable": true },
{ "state": "commissioned", "node_id": 7, "address": "192.0.2.10", "commissioned_at": "2026-06-06T12:00:00+09:00", "reachable": false, "stale": true },
{ "state": "commissioned", "node_id": 9, "address": "192.0.2.20", "commissioned_at": "2026-06-06T12:00:00+09:00", "reachable": null }
]
}Without --probe the output is unchanged (no reachable / stale); the
ledger is reported as-is and reflects no live reachability. Node-id matching
is best-effort (a cross-fabric node_id collision could false-positive); for a
deeper single-node check use mat diag node --deep.
commission output:
{ "timestamp": "2026-06-06T12:34:56+09:00", "node_id": 5, "status": "success" }Before the very first commission you need a fabric: a Root CA, the controller's
operational identity, and a random-epoch IPK, all written into a fresh
credential store. mat fabric init creates them (direct path only, no network
touched — it just writes the KVS):
# fabric init [--fabric-id N] [--admin-node-id N] (defaults: 1 / 112233)
mat fabric init{
"timestamp": "2026-06-06T12:34:56+09:00",
"store": "/home/you/.config/mat",
"fabric_id": 1,
"fabric_index": 1,
"compressed_fabric_id": "AAAAAAAAAAAAAAAA",
"admin_node_id": 112233
}- The generated IPK epoch is random (16 bytes from the OS CSPRNG), not
chip-tool's old fixedtemporary ipk 01constant. The key material never appears on stdout — only the fabric identifiers. - It refuses if the store already holds a KVS (no
--force); re-initialize by deleting the store's.inifiles by hand. Any other command run beforefabric initreturnsstore_missing(exit 10) with a hint to run it. - If you are joining a fabric that was created by
chip-tool(fixed-epoch), you do not runfabric init— the first nativecommissionverifies the fixed epoch against the fabric's KVS materials and adopts it (see Backend, "epoch").
Production Matter devices ship a DAC signed by a production PAA (Product
Attestation Authority). Without the matching PAA root, commissioning fails
attestation (device_rejected, "Failed Device Attestation"). Point mat at a
directory of PAA root certificates:
# Option 1: explicit env var
export MAT_PAA_TRUST_STORE=/path/to/paa-root-certs
# Option 2: drop the certs under the store, no env needed
# <store>/paa-trust-store/ (e.g. ~/.config/mat/paa-trust-store/)
mat commission --target 192.0.2.10 --setup-code "MT:Y.K9042C00KA0648G00" --node 5Resolution order: MAT_PAA_TRUST_STORE > <store>/paa-trust-store/. If neither
exists, mat trusts only the built-in development PAA (fine for test devices,
not for retail ones). Get the certificates from connectedhomeip's
credentials/production/paa-root-certs/. A CD (Certification Declaration) signer
trust store resolves the same way via MAT_CD_SIGNER_STORE >
<store>/cd-signer-store/ (absent = CD verification is warn-only).
<node_id> must be already commissioned (if not, exit 11; if the store
itself is missing, exit 10). Cluster / attribute / command names are passed in
chip-tool form (mat works in numeric / chip-tool terms; cluster /
attribute / command names are never aliased).
All device-addressing commands take named flags: --node (required),
--endpoint (defaults to 1), --cluster, --attribute, each with a short flag
(-n / -e / -c / -a) for terser typing. --node / --endpoint take the
numeric Matter identifiers; optionally, if <store>/aliases.toml exists, they
also accept a locally defined name that mat resolves to the number right after
arg parsing (see Aliases). Without that file,
numbers are the only form, exactly as before.
# Read an attribute (--endpoint defaults to 1)
mat read --node 5 --cluster onoff --attribute on-off
mat read -n 5 -c onoff -a on-off # same, short aliases
# Set a writable attribute
mat write --node 5 --cluster levelcontrol --attribute on-level --value 128
# Run a command: --command plus trailing command args
mat invoke --node 5 --cluster levelcontrol --command move-to-level 128 0 0 0
# Introspect a node
mat describe --node 5
# High-frequency shortcuts (--endpoint defaults to 1)
mat on --node 5
mat off --node 5 --endpoint 2
# Color temperature (ColorControl MoveToColorTemperature): give Kelvin and mat
# converts to mireds (round(1,000,000 / K)), or pass mireds directly. The two
# flags are mutually exclusive and one is required. --transition is in tenths
# of a second (30 = 3 s, default 0). Values outside the device's supported
# range are clamped by the device itself (mat does not pre-read or validate).
mat color-temp --node 5 --kelvin 2700
mat color-temp --node 5 --kelvin 2700 --transition 30
mat color-temp --node 5 --mireds 370
# Brightness (LevelControl MoveToLevel): give a percentage (0-100) and mat
# converts to the raw 0-254 level (round(percent / 100 * 254); 255 is
# reserved). --transition is in tenths of a second (30 = 3 s, default 0).
# Values outside the device's supported range are clamped by the device
# itself (mat does not pre-read or validate).
mat level --node 5 --percent 50
mat level --node 5 --percent 100 --transition 30
# Hue / saturation (ColorControl MoveToHueAndSaturation): --hue in degrees
# (0-360) and --sat in percent (0-100), both required. mat converts each to
# Matter's 0-254 scale (round(v / full * 254); 255 is reserved so full scale
# tops out at 254). --transition is in tenths of a second (default 0). Values
# outside the device's supported range are clamped by the device itself.
mat color --node 5 --hue 330 --sat 80
mat color --node 5 --hue 330 --sat 80 --transition 30
# Named colors and RGB: --name looks up a built-in table (red / pink / orange /
# purple / cyan / green / blue / yellow / magenta / white; extend or override
# via [colors] in aliases.toml), --rgb takes #rrggbb / rrggbb / R,G,B. Both are
# converted RGB -> HSV -> hue/sat; the V (brightness) component is discarded,
# so these set the color only and never change brightness (use LevelControl
# for that). `--name white` naturally lands on sat=0 (desaturate); color-temp
# can also produce white but through a different pipeline — both are kept.
# The three spec systems (--name / --rgb / --hue+--sat) are mutually exclusive.
mat color --node 5 --name pink
mat color --node 5 --rgb "#ff00aa"
mat color --node 5 --rgb 255,0,170Important asymmetry: read is an attribute, control is an invoke. Turning a
light ON/OFF is not a write of the OnOff attribute; it is an invoke of the
On/Off command. mat on / mat off are shortcuts for this and map to the
on / off command of the OnOff cluster as an invoke (not a write).
Outputs:
// read — the attribute's TLV value normalized to bool/number/string/null
{ "timestamp": "...", "node_id": 5, "endpoint": 1, "cluster": "onoff", "attribute": "on-off", "value": true }
// write
{ "timestamp": "...", "node_id": 5, "endpoint": 1, "cluster": "levelcontrol", "attribute": "on-level", "value": "128", "status": "success" }
// invoke (mat on / off have the same shape)
{ "timestamp": "...", "node_id": 5, "endpoint": 1, "cluster": "onoff", "command": "on", "status": "success" }
// color-temp — echoes both the input kelvin and the converted mireds so the
// result can be cross-checked against a `color-temperature-mireds` read
// (when --mireds is given, kelvin is back-computed the same way for the echo)
{ "timestamp": "...", "node_id": 5, "endpoint": 1, "cluster": "colorcontrol", "command": "move-to-color-temperature", "kelvin": 2700, "mireds": 370, "transition": 0, "status": "success" }
// level — echoes both the input percent and the converted raw level so the
// result can be cross-checked against a `current-level` read
{ "timestamp": "...", "node_id": 5, "endpoint": 1, "cluster": "levelcontrol", "command": "move-to-level", "percent": 50, "level": 127, "transition": 0, "status": "success" }
// color — echoes the input degrees/percent plus the converted 0-254 raw
// values so the result can be cross-checked against `current-hue` /
// `current-saturation` reads
{ "timestamp": "...", "node_id": 5, "endpoint": 1, "cluster": "colorcontrol", "command": "move-to-hue-and-saturation", "hue": 330, "saturation": 80, "hue_raw": 233, "saturation_raw": 203, "transition": 0, "status": "success" }
// color with --name / --rgb — additionally echoes the input name and the
// normalized #rrggbb so the conversion can be audited
{ "timestamp": "...", "node_id": 5, "endpoint": 1, "cluster": "colorcontrol", "command": "move-to-hue-and-saturation", "hue": 350, "saturation": 25, "hue_raw": 247, "saturation_raw": 63, "transition": 0, "name": "pink", "rgb": "#ffc0cb", "status": "success" }
// describe — lists child endpoints from endpoint 0's parts-list, and each
// endpoint's server-list as numeric cluster IDs
{ "timestamp": "...", "node_id": 5, "endpoints": [ { "endpoint": 0, "clusters": [29, 31] }, { "endpoint": 1, "clusters": [6, 8] } ] }
describeissues several reads (parts-list plus each endpoint's server-list) over one CASE session, so it does a bit of work, but it finishes in one shot.
mat diag thread --node <node_id> returns a one-shot snapshot of a node's Thread
Network Diagnostics (cluster 53, normally on endpoint 0) for analyzing mesh
health — "why is this device flaky?". It bundles the scalars routing-role /
network-name / extended-pan-id / pan-id / partition-id / channel with
the list attributes neighbor-table and route-table, which the generic mat read can't represent (they are lists of structs, not a single value).
# diag thread --node <node_id> [--endpoint EP] (EP defaults to 0)
mat diag thread --node 5// routing_role etc. are numeric enums (mat does not resolve names);
// neighbor_table / route_table are arrays of objects with chip-tool field names.
{
"timestamp": "...", "node_id": 5, "endpoint": 0,
"thread": {
"routing_role": 5, "network_name": "ha-thread-6562",
"extended_pan_id": 14789548233599576168, "pan_id": 25954,
"partition_id": 597971536, "channel": 15,
"neighbor_table": [
{ "Age": 21, "ExtAddress": 7110405590318074745, "Rloc16": 38912, "Lqi": 3, "AverageRssi": -65, "LastRssi": -67, "FrameErrorRate": 56, "RxOnWhenIdle": true, "IsChild": false }
],
"route_table": [
{ "ExtAddress": 7110405590318074745, "Rloc16": 38912, "RouterId": 38, "NextHop": 45, "PathCost": 1, "LQIIn": 3, "LQIOut": 3, "LinkEstablished": true, "Allocated": true }
]
}
}Field names inside
neighbor_table/route_tablefollow chip-tool's field-name convention (noteLqiin neighbors butLQIIn/LQIOutin routes), androuting_roleis the numeric enum (5 = Router) —matdoes not resolve names.
How to read it: a flaky node usually has few
neighbor_tableentries or a weakAverageRssito its only neighbor (roughly: > -70 dBm healthy, < -85 dBm marginal). Only mains-powered, router-eligible devices relay (RxOnWhenIdle: true/ notIsChild); adding battery sleepy end devices does not extend the mesh. Devices that share the sameextended_pan_idare on the same Thread network (same border router); apartition_idthat differs across nodes means the mesh has split.Thread devices drop in and out, so
diagreturns partial results: each attribute is read independently, failures are listed underunavailable([{ "attribute": ..., "kind": ... }]), and an unread field isnull— distinct from[], which means a table was read and is genuinely empty (an isolated node). If every read fails (node fully unreachable) it exits withunreachable/timeoutinstead. Likedescribe, this reads several attributes but finishes in one shot.mat diagruns only on the direct path (not via--matd).
mat diag node --node <node_id> answers a different question: why can't I
control this commissioned node? It runs layered checks and classifies the
result into a single verdict with the evidence and a recommended action —
where mat invoke would only return a bare timeout / session_failed.
# diag node --node <node_id> [--endpoint EP] [--deep] (EP defaults to 0)
mat diag node --node 5 # fast (native IM: operational + thread checks)
mat diag node --node 5 --deep # also probe ping6 + native targeted mDNS{
"timestamp": "...", "node_id": 5, "endpoint": 0,
"verdict": "link_starved",
"summary": "IP reachable but not advertising Matter on any fabric; weak Thread link — SRP registration likely incomplete.",
"checks": {
"ip": { "ok": true, "loss_pct": 50, "rtt_ms": 168.0, "method": "ping6" },
"mdns": { "advertised_self_fabric": false, "advertised_any_fabric": false },
"operational": { "resolved": false, "kind": "timeout" },
"thread": { "neighbor_count": 1, "best_lqi": 3, "routing_role": 2 }
},
"recommendation": "Improve the Thread link (move the device near a router) or wait; do NOT factory reset — the fabric is intact."
}
verdictis one ofok,ip_unreachable,link_starved,fabric_missing,not_advertised,unresolvable,session_failed,device_rejected,unknown. Without--deepthe fast path can't telllink_starved(weak Thread link, SRP not registered — the fabric is intact) apart fromfabric_missing(the device dropped our fabric);--deepadds the ping6 and mDNS evidence that distinguishes them. Likediag threadit returns partial results (skipped/failed checks go underunavailable) and always exits0with a verdict, even when the node is fully unreachable — the value is in the classification, not an exit code.--deepshells out toping6(override withMAT_PING6_BIN) and does a native targeted mDNS resolve.The
operationalandthreadchecks run natively over a single CASE session (one session serves both).diag nodeis direct path only — it is not part of thematdprotocol.
mdns.advertised_self_fabricis whether the node advertises on our fabric specifically (vs.advertised_any_fabric, which is any fabric). It needs our compressed-fabric-id, whichmatcomputes directly from the fabric's KVS materials — so it is always available (the historicalcfid_unavailablecase, from the old chip-tool-log-parsing path, cannot occur on the native path).
mat diag mesh answers a third question: what does the whole Thread mesh
look like? It probes each fabric node's Thread Network Diagnostics (cluster
53) and NetworkInterfaces (cluster 0x33, for self-identification) in turn and
assembles the results into one node/edge topology graph — neighbor and route
table rows that name a participant mat never commissioned (an OTBR border
router, a device on another fabric) become graph nodes too, so the mesh is
visible even where the fabric does not reach.
# diag mesh [--nodes N|ALIAS ...] (omit --nodes = every commissioned node)
mat diag mesh
mat diag mesh --nodes 5 16// dummy values; ExtAddress / rloc16 are hex, ids are the graph's stable keys.
{
"timestamp": "...",
"network": {
"name": "ha-thread-6562", "channel": 15,
"partition_ids": [597971536], "leader_router_id": 8
},
"nodes": [
{ "id": "ext:0011223344556677", "ext_address": "0011223344556677",
"rloc16": "0x1400", "router_id": 5, "role": "router",
"node_id": 42, "alias": "hall_motion", "probed": true },
{ "id": "ext:8899AABBCCDDEEFF", "ext_address": "8899AABBCCDDEEFF",
"rloc16": "0x0c01", "role": "child", "node_id": 5, "probed": true },
{ "id": "ext:AABBCCDDEEFF0011", "ext_address": "AABBCCDDEEFF0011",
"rloc16": "0x2000", "router_id": 8, "role": "leader", "label": "otbr-br" }
],
"edges": [
{ "a": "ext:0011223344556677", "b": "ext:8899AABBCCDDEEFF",
"a_sees_b": { "lqi": 140, "avg_rssi": -60, "last_rssi": -58, "frame_error_rate": 2, "age": 12 },
"b_sees_a": { "lqi": 130, "avg_rssi": -65, "last_rssi": -64, "frame_error_rate": 5, "age": 8 } },
{ "a": "ext:0011223344556677", "b": "ext:AABBCCDDEEFF0011",
"a_sees_b": { "lqi": 200, "avg_rssi": -50, "last_rssi": -49, "frame_error_rate": 0, "age": 3 },
"b_sees_a": null,
"route": { "lqi_in": 3, "lqi_out": 3, "path_cost": 1 } }
]
}
--nodestakes node_ids oraliases.tomlnode aliases, one or more, deduped preserving first-seen order (--nodes 7 7, or the same node given twice via alias + id, probes it once and emits one graph node —idis the graph's stable key, so a duplicate target would otherwise duplicate the node); omitted, it means every commissioned node in the store. A node with 0 targets (empty store) is not an error — it returns an empty graph ("nodes":[],"edges":[]) plus anetworkobject (all fields absent except an emptypartition_ids) and atimestamp, without touching the backend.A node's stable
idisext:<HEX16>when its ExtAddress is known (either self-identified via cluster 0x33, for fabric nodes, or observed in a neighbor/route table row, for unknown participants),rloc:<hex>when only the RLOC16 could be derived (reserved — unused by the current implementation: RLOC16 derivation depends on the same ExtAddress canonicalizationext:needs, so a canonicalization failure suppresses both), andnode:<node_id>for a fabric node whose probe never got far enough to read either (e.g. cluster 0x33 unreadable). Unknown participants get alabelfromaliases.toml's[thread]section (see Aliases above) instead of analias, which is reserved for commissioned nodes' own node alias.A fabric node
matcould not self-identify (probe failure, cluster 0x33 unreadable, or a duplicate self-ext claim invalidated below) shows up as anode:<id>graph node — and still contributes its own viewpoint edges, anchored atnode:<id>rather than anext:vertex, from its own neighbor/route table rows. The same physical radio may also surface separately as anext:unknown-participant node if another probed node's neighbor/route table observed it.matdoes not correlate the two into a single entry today — merging them is future work.Real-device finding (2026-07-23): some Thread devices (ESP32-based tape lights) report an identical, firmware-hardcoded HardwareAddress on cluster 0x33 across every unit (with an empty IPv6Addresses list). Since that ext claim is physically impossible once two or more fabric nodes make it,
matinvalidates the self-identification of all nodes that claimed the same ext — they fall back tonode:<id>(see above) rather than merging into one bogus graph vertex. Separately, some devices encodemesh-local-prefixas a length-prefixed octstr (0x40= 64-bit length byte + 8-byte prefix, 18 hex chars) rather than the bare 8-byte form (16 hex chars);matnormalizes both to the 16-hex form before deriving RLOC16. A route-table row withExtAddress: 0is treated as garbage and ignored (no node, no edge).Like
diag node,diag meshis direct path only (native, not part of thematdprotocol) and always fixes on endpoint 0 (cluster 53 / 0x33 are normally endpoint 0). Collection is sequential, one CASE session per node, so wall-clock time scales with node count — a handful of seconds per node, so an 8-node mesh takes on the order of tens of seconds.A single node's probe failure does not fail the whole command: it shows up as
"probed":falseplus aprobe_error({"kind":...,"detail":...}) on that node, and the command still exits0with the partial graph — same philosophy asdiag thread's per-attributeunavailable. Only when every targeted node's probe fails doesdiag meshexit non-zero, mapped from the most common failurekindacross nodes (e.g. all nodesunreachableexits5; a tie is broken by first-seen kind).
mat listen streams attribute-change events from matd's resident wildcard
Subscribe (occupancy sensors, open/close, temperature/humidity, on-off, ...) —
mat/matd's alternative to depending on Home Assistant for these. It is
the first matd-only op: there is no direct-native fallback, because a
subscription needs a resident daemon to stay alive between calls (see
Routing through matd).
mat listen [--node <id|alias>] [--endpoint <n>] [--cluster <name>] [--attribute <name>]
[--count <N>] [--timeout-ms <T>]- Filters (
--node/--endpoint/--cluster/--attribute) narrow which events are delivered; omitted filters match everything.--nodeaccepts a node alias the same way other commands do;--cluster/--attributeare chip-tool notation (never aliased), same asread. If<store>/subscriptions.tomlexists, only its listed clusters are ever subscribed bymatdin the first place, so--clustercan narrow further within that set but never outside it — see Subscriptions (subscriptions.toml, optional, matd only) below. --count(default1) is how many events to receive before exiting0;0means no count limit — keep streaming (symmetric with--timeout-ms 0).--timeout-ms(default60000) cuts the wait short;0means wait forever. Reaching--countexits0; the timeout firing with zero events received exits3(with at least one event received, it still exits0— same UX asenl listen).matconnects tomatd, sends thelistenrequest, and prints the ack line followed by one JSON event per line to stdout as they arrive:{"timestamp":"...","listening":true} {"timestamp":"2026-07-20T21:00:00+09:00","node_id":21,"endpoint":1,"cluster":"occupancysensing","attribute":"occupancy","value":1,"priming":false,"recovered":false}priming: truemarks events from the initial report burst right after matd (re)establishes a subscription, so a consumer does not mistake matd-restart residual state (e.g.occupancystill1from before a restart) for a fresh trigger. Only scalar values become events —list/structattributes (ACL, server-list, etc., which show up in a wildcard priming burst) are dropped, the same known limitation as genericread(see Scalar-only generic write / invoke).recovered: truemarks an eventmatdreconstructed from a priming report: the attribute's value in the new subscription's priming burst differs from the last valuematdsaw, so the transition happened while the subscription was down. Such an event is delivered withpriming: falseandrecovered: true, so an existing consumer trigger fires on it without any change. Itstimestampis the receive time, not the time of the actual transition (which is unknowable — somewhere in the blind window). Valuesmatdhas never seen before (first priming after amatdrestart) are not promoted: they are plainpriming: trueevents, so a restart never fires a consumer's automation.- Promotion applies to any subscribed attribute whose value changed, not
just the ones a consumer cares about — after a blind window a full-wildcard
subscription can promote diagnostics counters and similar monotonic
attributes too. Narrowing via
subscriptions.tomlor themat listen --cluster/--attributefilters is the control. - A transition that
matd's own op caused during the blind window also comes back asrecovered: true(matd never observed the report either). A consumer whose rule is toggle-shaped should key off the value, not the event's arrival. matdabsent, refusing the connection, or dying mid-stream ismatd_unavailable(exit 13) — see Errors and exit codes. Events already printed before a mid-stream matd loss stay printed; the process still exits13(not3), even if--countwas not reached.- Usage form (a consumer like casa reacts per line;
mat/matdnever run automations — see Backend / ARCHITECTURE.md "Design rules"):Prefer this resident stream over respawning one-shotmat listen --node 21 --cluster occupancysensing --count 0 --timeout-ms 0 | while read -r ev; do # inspect $ev and react, e.g. mat on / mat off done
--count 1calls in a loop:matdfans events out over a live broadcast, so events arriving in the gap between onelistenexiting and the next attaching (e.g. the rest of a priming burst after its first event) are dropped, not queued.
See Routing through matd for what matd actually
subscribes to and how events reach it.
To share a mat-owned device with another controller (Alexa / Apple / Google),
open a commissioning window and return a one-time issued code. This runs the
Administrator Commissioning cluster's OpenCommissioningWindow (ECM) natively
over a CASE session to the node.
# open-window --node <node_id> [--timeout S] [--iteration N] [--discriminator D]
mat open-window --node 5
mat open-window --node 5 --timeout 300Output:
{ "timestamp": "...", "node_id": 5, "manual_code": "36217551492", "qr_payload": "MT:-24J0AFN00KA0648G00", "expires_at": "2026-06-06T12:37:56+09:00" }- Returns both
manual_code(11-digit) andqr_payload(theMT:...string). - Rendering the QR image is not
mat's job. stdout emits theqr_payloadstring only; drawing is out of scope. --timeoutdefaults to 180 seconds.expires_atis the timematbuilt the response plustimeout.- If
--discriminatoris omitted, it is derived from the node_id deterministically (kept within 12 bits). - "Share many devices in one QR" is not possible in Matter (one commission
per device). Fronting many devices is a bridge, a separate project, not
mat.open-windowshares native devices one at a time. - Watch the fabric count limit. A cheap node may support only ~5 fabrics, so
several admins plus
matcan use up the slots. When a hub acts as a bridge,matdoes multi-admin with the one hub, and its sensors appear as bridged endpoints.
Control many devices at once with a Matter wire group: a GroupId plus a key
set is burned into each device, then a single multicast send hits all of them.
This is the original motivation (no "popcorn effect" of lights turning on one by
one). mat runs the whole path natively: the device-side Group Key Management /
Groups writes over CASE, and the controller-side group state written straight
into the credential KVS (mat is the sole owner/writer of that state, in
chip-tool-compatible INI form). Logical group names ("the living-room lights")
are out of scope —
mat takes a numeric GroupId (-g/--group and --nodes also accept an
alias from the optional aliases.toml, which is just a local nickname for the
number; see Aliases).
# Provision: burn the key set + mapping + ACL group entry into every node, and
# set up the controller-side group state. --group is the GroupId, --nodes one
# or more commissioned node_ids.
# provision --group <ID> --nodes <N>... [--keyset-id N] [--name NAME]
# [--endpoint EP] [--epoch-key HEX]
mat group provision --group 1 --nodes 5 6 7 --name living
# Add a node to an existing group: pass --rebind with ALL existing members plus
# the new one, and the SAME --keyset-id the group already uses.
mat group provision --group 1 --nodes 5 6 7 8 --name living --rebind
# Invoke: one multicast send to the group (unacknowledged).
# invoke --group <ID> --cluster <NAME> --command <NAME> [args...] [--endpoint EP]
mat group invoke --group 1 --cluster onoff --command on
# Grant (repair): run just the ACL step on already-provisioned nodes. Use it for
# groups provisioned before the ACL step existed (or through an old matd).
# Idempotent: nodes that already have the entry are reported as "unchanged".
# grant --group <ID> --nodes <N>...
mat group grant --group 1 --nodes 5 6 7Outputs:
// provision — all listed nodes succeeded (provision stops at the first failure)
{ "timestamp": "...", "group_id": 1, "keyset_id": 42, "name": "living", "endpoint": 1, "nodes": [5, 6, 7], "status": "provisioned" }
// provision --rebind via the direct path also notes the matd restart caveat
{ "timestamp": "...", "group_id": 1, "keyset_id": 42, "name": "living", "endpoint": 1, "nodes": [5, 6, 7, 8], "status": "provisioned", "note": "rebound keyset binding; if matd is running, restart it to reload group state" }
// provision when the controller-side write went native (MAT_IFACE/MAT_MATD_IFACE
// set, M8c-2) always carries this note instead — regardless of --rebind
{ "timestamp": "...", "group_id": 1, "keyset_id": 42, "name": "living", "endpoint": 1, "nodes": [5, 6, 7], "status": "provisioned", "note": "controller group state written natively to kvs; if matd is running, restart it to reload group state" }
// invoke — multicast is fire-and-forget; only "sent" can be reported
{ "timestamp": "...", "group_id": 1, "cluster": "onoff", "command": "on", "endpoint": 1, "status": "sent", "note": "unacknowledged groupcast; per-device delivery not confirmed" }
// grant — per-node repair result (ACL updated vs already had the entry)
{ "timestamp": "...", "group_id": 1, "nodes": [5, 6, 7], "updated": [5, 7], "unchanged": [6], "status": "granted" }- Groupcast is unacknowledged.
group invokereports"sent", never "all 7 turned on." There is no per-device result and no read-after-write check at the group level — confirm individual devices withmat readif needed. --epoch-keyis optional. It is the 16-byte (32-hex) AES key shared by the group. Omit it andmatgenerates a random one (single-controller use); pass a fixed key only when several controllers must share the same wire group. The key is never printed to stdout (it is a credential; it lives in the KVS).--keyset-iddefaults to 42,--nametogrp<group_id>,--endpointto 1.- Provision is heavy and fragile (KeySetWrite / GroupKeyMap / AddGroup / ACL write on every node) and especially unstable on Thread (multicast retransmits and IPv6 packet drops lower delivery). Wi-Fi / Ethernet Matter lights fare better.
- It stops at the first failed node/step (the error
detailsays which) so stdout stays pure JSON; re-run after fixing the offending node. - Provision also writes the device ACL (its 4th per-node step). Group
commands arrive with authMode=Group, so each device needs an ACL entry
{privilege: Operate, authMode: Group, subjects: [GroupId]}— commissioning only creates the CASE admin entry, and without the group entry every device silently drops the groupcast (it is unacknowledged, so nothing fails visibly). The step is a read-merge-write:matreads the current ACL, appends the entry only when missing (idempotent, existing entries — including other groups' — are preserved), and writes the full list back. If the ACL read cannot be parsed,matstops withparse_errorand never writes (an ACL write replaces the whole list; a blind write could drop the admin entry and make the device unmanageable). - Adding a node to an existing group:
--rebind. The controller-side group state persists across runs (in the credential KVSmatwrites directly), so re-running provision on an existing group fails with a duplicate-bind error (use --rebindin thedetail) — worse, the earlier keyset-add step has already rotated the controller's epoch key, leaving it out of sync with the devices (groupcast silently breaks). Without--rebindthis failure is intentional (it stops you from rotating keys by accident). With--rebind, provision unbinds the keyset binding first (best-effort; also safe on a brand-new group) and re-provisions cleanly. Three rules: pass all existing members plus the new node to--nodes(a fresh epoch key is generated, so nodes left out stop receiving groupcasts), keep the same--keyset-id(the device keyset table holds max 3 entries and the IPK uses one), and confirm membership per node withmat read -e 0 -c groupkeymanagement -a group-key-map. After a direct-path--rebind, restartmatdif it is running (it may still hold the old group state in memory; the KVS is already updated) — the outputnotesays so (see Outputs above). mat group grantrepairs older groups. Groups provisioned before this step existed — including any provision routed through amatd≤ 0.12, which does not run the ACL step — lack the entry and their groupcast is silently ignored. The controller-side group state is not idempotent, so provision cannot simply be re-run — useprovision --rebindto re-run it on an existing group;grantruns just the ACL step instead. It is direct path only (--matdexits 2).
Color / brightness shortcuts for groups (same conversions as the single-node
mat color-temp / mat color / mat level, delivered as an unacknowledged
groupcast — the result is "status": "sent" only; per-device delivery is not
confirmed). Like all ColorControl / LevelControl commands sent with
optionsMask=0, they only take effect on devices that are currently on:
mat group color-temp --group 1 --kelvin 2700
mat group color --group 1 --name pink
mat group color --group 1 --rgb "#ff00aa" --transition 30
mat group color --group 1 --hue 330 --sat 80
mat group level --group 1 --percent 100Each mat call is a one-shot: it establishes CASE, runs the op, and discards
the session. With a running matd the call is routed through its warm
session instead — same subcommands, same JSON on stdout, but the handshake is
skipped on repeated calls. mat auto-detects matd: for supported
subcommands it tries a connect on the default socket candidates, uses matd when something
answers, and silently falls back to mat's own native direct path when nothing
does (missing and stale sockets alike).
# Start the resident daemon (separate binary; see ARCHITECTURE.md / matd --help).
# With no --socket it binds the default path ($XDG_RUNTIME_DIR/matd/matd.sock,
# dir auto-created 0700; /tmp/matd.sock without XDG_RUNTIME_DIR) — the first
# default mat probes below.
matd &
# No flag needed: mat finds the running matd on the default socket by itself.
mat read --node 5 --cluster onoff --attribute on-off
mat describe --node 5
mat group invoke --group 1 --cluster onoff --command on
# Force the matd path (connection failure becomes an error instead of a
# fallback); pass a path to use a non-default socket.
# Caution: `--matd` takes an optional value (num_args = 0..=1), so a
# value-less `--matd` placed *before* the subcommand swallows the
# subcommand name as the socket path and fails to parse. Put it after the
# subcommand instead (or give it a value, e.g. `--matd=<path>`).
mat read --node 5 --cluster onoff --attribute on-off --matd
mat --matd /run/mat/matd.sock on --node 5
export MAT_MATD=1 # same, for a whole shell session
# Opt out (always direct path, no probing):
MAT_MATD=0 mat read --node 5 --cluster onoff --attribute on-off
# export MAT_MATD_SOCKET=/run/mat/matd.sock # pins which socket to probe/useStop the daemon with matd stop, which sends a shutdown request over the same
socket and triggers a graceful teardown (warm sessions dropped, socket removed):
matd stop # default socket
matd stop --socket /run/mat/matd.sockOnly one matd runs per socket: startup takes an exclusive flock on
<socket>.lock, so a second launch on the same socket exits 1 with matd already running (lock held at ...) instead of silently hijacking it.
matd is native and pure Rust — it speaks a plain unix-socket protocol and
holds warm per-node CASE sessions in-process (a few KB each). There is no child
process and no CPU busy-loop, so sessions are held indefinitely (no idle
reaper). It starts even with no fabric materials — each op returns
store_missing (exit 10) until you run mat fabric init — and refuses to start
only when interface autodetect is ambiguous (set MAT_MATD_IFACE).
- Route selection:
--matd/MAT_MATD=<truthy>force the matd path (connection failure is an error, no fallback).MAT_MATD=<falsy>(0/false/no/off) forcesmat's own direct path, no probing. Otherwise (default)matauto-detects: it probes the socket with a connect and falls back to the direct path when nobody answers.MAT_MATD_SOCKETjust selects which socket in every mode. - Socket path precedence (all modes):
--matd <path>>MAT_MATD_SOCKET=<path>(a single socket in both cases) > default candidates, probed in order:$XDG_RUNTIME_DIR/matd/matd.sock(the systemdRuntimeDirectory=matdconvention, matd's own bind default) then the pre-0.27.0$XDG_RUNTIME_DIR/matd.sock(transition compat); just/tmp/matd.sockwithoutXDG_RUNTIME_DIR. Stale sockets fail the connect and fall through naturally. - Once connected, errors are reported from the matd path as-is —
matnever re-runs the command on the direct path (no double execution of writes). Which path ran is logged to stderr at info level (MAT_LOG=info). - Supported over matd:
read/write/invoke/on/off/color-temp/color/level/describe/group(provision/invoke/color-temp/color/level;group grantis direct only — see Groupcast above).discover/commission/fabric init/open-window/diagare direct-only: auto-detection skips them silently; explicit--matdexits2.listen(below) is the opposite case — it is matd-only, with no direct-path fallback at all (not even auto-detect skip-and-run-direct); without a reachablematdit ismatd_unavailable(exit13). - node_id commissioning is re-checked by
matdagainst the same credential store per request, so the error kinds and exit codes match the direct path.
At startup matd reads the commissioned-node ledger and opens one wildcard
Subscribe per node (every endpoint/cluster/attribute — the same "all-paths
omitted" shape as a wildcard read), so device-originated attribute changes
(occupancy, open/close, temperature, on-off, ...) are captured continuously,
not just when a mat caller happens to be polling.
- Subscribe parameters:
MinIntervalFloor = 0(no artificial delay on fast-changing sensors like occupancy),MaxIntervalCeiling = 300s(the device still picks the actual interval; a device on a flaky Thread link silently discards its subscription when report delivery fails, and the keepalive cadence is the only liveness signal the subscriber gets — 300s bounds that blind window to ≤7.5 min, where the original 3600s left matd blind for up to 90 minutes),KeepSubscriptions = false(a re-subscribe replaces rather than piles onto the device's existing subscription table). - A subscription that fails to establish, or that goes silent for more than
1.5× its negotiated MaxInterval (subscription-death detection), is
re-subscribed with exponential backoff starting at 5s, capped at 5 minutes.
Retries are logged at
debug; only the established/lost state transitions are logged atinfo— a flaky Thread node re-subscribing every few seconds does not spam the log. - Events fan out from each subscription's report pump through one
tokio::sync::broadcastchannel to every connectedmat listenclient, filtered per client. A listener that falls behind and misses events on the channel gets a single{"error":{"kind":"other","detail":"event stream lagged"}}line and is then disconnected — never silently dropped events. matdholds no event history (no ring buffer, no replay): amat listenclient only sees events emitted while it is connected, same asenl listen.priming(see Listen) is the mechanism for telling initial-state reports apart from later changes without needing a replay log.listenis the only op that breaks the "one line request = one line response" rule of thematdsocket protocol: it replies with one ack line ({"timestamp":...,"listening":true}), then keeps the connection open and streams matching event lines until the client disconnects.- v1 scope is attribute reports only. Not yet implemented (tracked as
future work): EventReport delivery (buttons / Generic Switch), a
DataVersionFilter, and LIT ICD check-in registration. Cluster-level narrowing of what gets subscribed is implemented — see Subscriptions (subscriptions.toml, optional, matd only) below.
mat and matd share one engine (crate mat-native, sitting on the protocol
library mat-controller). matd holds warm per-node CASE sessions; mat
establishes → runs one op → discards (design rule 4). The stdout JSON schema is
identical either way — the process only differs in session lifetime.
The engine needs the Thread-mesh network interface. mat auto-detects it
every run (no stored state): the sole interface that is up (has carrier),
multicast-capable, non-loopback, non-point-to-point (tunnels like tailscale0
are excluded), and holds an IPv6 link-local address. If exactly one qualifies it
is used; zero or two-or-more is a hard error (other) that lists the candidates
and asks you to set the override.
MAT_IFACE(or the global--iface <name>) overrides autodetect format.MAT_MATD_IFACE(ormatd --iface <name>) overrides it formatd. These are deliberately separate names for two different processes;matdrefuses to start on an ambiguous autodetect (a whole-daemon misconfiguration, so it fail-fasts rather than erroring per-op).
On jarvis (eth0 + tailscale0) and WSL (eth0) exactly one candidate remains,
so autodetect just works.
MAT_FABRIC_INDEX(default1) andMAT_ISSUER_INDEX(default0) select the KVS fabric-table and CA-issuer entries format;matdmirrors them asMAT_MATD_FABRIC_INDEX/MAT_MATD_ISSUER_INDEX(also--fabric-index/--issuer-index). Pass the same values to both on the same host. If you share a fabric with another admin the index is usually not1.- Warm sessions (matd only) are held per node indefinitely. A send that
exhausts MRP retransmission (timeout) discards the session and does one
automatic mDNS re-resolve + re-CASE before failing.
mat's one-shot session can't be stale, so it never retries — a failure is reported as-is. - Epoch (IPK).
commissionneeds the fabric's epoch IPK (the keyAddNOChands the device — distinct from the KDF-derived operational key that is the only one persisted). It is resolved in order: (1) themat-owned KVS keymat/f/<idx>/ipk-epochif present; (2) otherwise the fixed chip-tool default is checked against the fabric's KVS materials via a KDF guard, and on a match it is adopted and persisted to that key (so a fabric first created by chip-tool keeps working, and later commissions read the persisted value); (3) a mismatch (rotated IPK, or a non-chip-tool fabric) is astore_parsehard error. A fabric created bymat fabric initstarts at case (1) with a random epoch. Adoption happens on the first native commission — no separate step.
Generic write / invoke (and group invoke) encode scalar JSON→TLV types
only: bool / int / uint / enum / bitmap / string / octstr (bytes as a
hex:-prefixed string). An attribute or command field the name table knows to be
list / struct / float is rejected up front with parse_error (the detail
names the type). This is a deliberate, documented limitation — the practical
cases (onoff / level / color, and the ACL entry group grant appends) are all
covered, and the numeric-ID escape hatch remains for names the generated table
does not resolve (an unknown name is also a parse_error; pass the numeric id).
The group provision / grant list/struct writes (KeySetWrite, GroupKeyMap,
binding, ACL read-modify-write) use dedicated encoders, not the generic path.
Native groupcast is a single unacknowledged AES-CCM-sealed packet to the
site-local transient multicast address (ff35::.../64, hop limit 64) — no
response, no MRP. The per-sender counter is persisted at
<store>/native_group_counter (plain decimal, written ahead by 4096 so a crash
never reuses a value), opened under an exclusive flock on <path>.lock for the
life of the process.
matandmatdshare this one file. Whichever process holds the lock sends with it; the other finds it locked (WouldBlock) and reports the group op as unavailable rather than racing the counter. Because both send as the same source node id, they share one per-sender counter window on the receiving devices.- Pick one group sender. If a native
matdis running, send all groupcasts through it. Its warm engine re-reads the group's operational credentials from the KVS on every send, so agroup provision --rebindtakes effect on the very next send with no restart. Do not mix senders: once one has advanced the counter, the other is behind, and devices silently drop its groupcasts as stale/duplicate (atracing::warn!is logged, but routing is unchanged — refusing the send is a product decision, not made here). Withmatdrunning, route priority already sends every group op to it first, so you normally never reach forMAT_MATD=0.
discover, commission, fabric init, open-window, diag thread / diag node / diag mesh, and group grant are not part of the matd socket
protocol at all (by design — rare, or no warm session to reuse). They always
run on mat's own one-shot direct path (native), even when a matd is
running. discover --probe
and diag node --deep do a native targeted mDNS resolve per ledger node
(run concurrently), not a service-type enumeration: real Thread meshes have
advertising proxies that answer direct instance queries but omit instances from
PTR enumeration, so enumerate-and-match under-reports.
mat --iface eth0 on --node 5
# or: MAT_IFACE=eth0 MAT_FABRIC_INDEX=2 mat group invoke --group 10 --cluster onoff --command on
matd --iface eth0 & # or MAT_MATD_IFACE=eth0 matd &Resolution order: --store <path> > $MAT_STORE > $XDG_CONFIG_HOME/mat >
~/.config/mat. It holds the Root CA, the controller's keys/cert, the
commissioned-node ledger (nodes.json), the optional alias map
(aliases.toml, below), and the persistent Matter KVS (chip-tool-compatible
INI form — group keysets, operational credentials, the group-send counter;
mat is its sole reader/writer). It is never committed (excluded by
.gitignore).
Numeric node / group / endpoint ids are easy to get wrong. If the file
<store>/aliases.toml exists, mat resolves locally defined names to those
numbers right after arg parsing — a purely local convenience. Without the
file, behavior is exactly the traditional numeric-only one. The wire and the
backend (native engine / matd) always receive numbers, and stdout keeps the
numeric schema (no alias echo-back).
version = 1
[nodes]
living-light = 5
hall-sensor = 12
[groups]
all-lights = 258
[endpoints.living-light]
main = 1
night = 2
[endpoints.12]
pir = 3
[colors]
warm = "#ff8c00"
mypink = "255,182,193"
[thread]
"AABBCCDDEEFF0011" = "otbr-br"nodes: alias → node_id. Accepted by-n/--node(read / write / invoke / describe / on / off / color-temp / color / level / open-window / diag thread / diag node) and by--nodesingroup provision/diag mesh(each element resolved independently).groups: alias → GroupId. Accepted by-g/--groupin everygroupsubcommand (provision/invoke/grant/color-temp/color/level).endpoints: defined per node — the outer key is a node alias or a node_id digit string, the inner map is alias → endpoint number (endpoint numbers mean different things on different nodes, so there is no global endpoint dictionary). Accepted by-e/--endpointon node-taking commands; the lookup uses the resolved node, so-n 5 -e mainand-n living-light -e maingive the same result. The-eofgroup provision/group invoke/group color-temp/group color/group levelis numeric only (no node context to resolve against).colors: custom color name → RGB value (#rrggbb/rrggbb/R,G,B), used by--nameincolor/group color. Entries are defined as RGB and go through the same RGB → HSV pipeline as--rgb. A user-defined name overrides the built-in color table (you can redefinered). Without the file the built-in table still works. A value that does not parse as RGB isstore_parse(exit10); an unknown color name is a CLI argument error (exit2) listing the known names.thread: Thread ExtAddress (16 hex, case-insensitive) → display label, used bymat diag meshto name unknown participants (OTBR border routers, other- fabric devices) that show up in a neighbor/route table but were never commissioned onto this fabric, so they have nonodesalias to fall back on. The graph'slabelfield matches on ExtAddress regardless of fabric status, so a commissioned node whose ExtAddress happens to be listed here gets alabeltoo, alongside itsnodesalias.
# With the aliases.toml above, these are equivalent:
mat on -n living-light
mat on -n 5Resolution rules:
- A value that parses as a number is used as-is (numbers win; full backward
compatibility). Only non-numeric values are looked up in
aliases.toml. - Alias names must be non-empty and not all digits (this shadowing is rejected
when the file is loaded:
store_parse, exit10). - An unknown alias — or any alias given when there is no
aliases.tomlin the store — is a CLI argument error (exit2); the stderrdetaillists the known aliases (or saysno aliases.toml in store) so the caller can self-correct. - A corrupt
aliases.tomlisstore_parse(exit10). - Cluster / attribute / command names are never aliased (chip-tool notation only).
These map onto the existing exit codes (2 / 10); the
Errors and exit codes table is unchanged.
To register an alias while commissioning, add --alias:
mat commission --target 192.0.2.10 --setup-code "MT:Y.K9042C00KA0648G00" --node 5 --alias living-lightThe name is validated before commissioning starts (all-digits / empty /
already taken → exit 2, before any network op runs), and it is written
to aliases.toml only on success (the file is created if absent). Without
--alias, commission never touches aliases.toml. Deleting or renaming an
alias is a hand edit of the file — there is no management subcommand.
By default matd's resident Subscribe (see Resident Subscribe and mat listen) is a full wildcard: every
endpoint/cluster/attribute, on every commissioned node. If
<store>/subscriptions.toml exists, matd narrows that to just the listed
clusters' paths instead.
Full-wildcard priming (the initial full-attribute dump right after a
subscription is (re)established — dozens of request/response round trips) can
fail to complete on a weak Thread link, leaving a subscription unestablished
for tens of minutes to hours. Narrowing to a handful of clusters shrinks
priming to one or two chunks, so a link good enough for read is usually
good enough to subscribe on too.
clusters = [
"onoff",
"occupancysensing",
"temperaturemeasurement",
]- Cluster names use chip-tool notation (same as
mat read); numeric ids ("0x0006"/"6") also work, the same escape hatch as elsewhere for namesmat-core::idsdoesn't know. - Absent file = full wildcard, unchanged — the same absent-file discipline
as
aliases.toml. - A parse failure, an unknown cluster name, or an empty list makes
matdrefuse to start (store_parse, exit10); it never silently falls back to wildcard, so a misconfiguration can't quietly disable the weak-link workaround. - Edge case: nodes that serve none of the listed clusters. When the file is present, the narrowed Subscribe is sent to every commissioned node; a node that exposes none of the listed clusters will never establish its subscription (it retries on backoff forever). Ensure each node serves at least one of the listed clusters.
- When this file is present,
mat listenonly ever sees events for the listed clusters — a--clusterfilter naming a cluster outside that set simply never matches anything. - Read once at
matdstartup; an edit needs amatdrestart to take effect (e.g.systemctl --user restart matd). mat(one-shot) never reads this file — like the rest of the resident Subscribe, it is matd-only.
Errors go to stderr as {"error":{"kind":"...","detail":"..."}}.
| code | meaning |
|---|---|
| 0 | success |
| 2 | CLI argument error (clap default) |
| 10 | credential store missing / parse failure |
| 11 | node_id not commissioned |
| 12 | (retired in 0.22.0 — historical vacancy) |
| 3 | timeout |
| 4 | device rejected |
| 5 | unreachable / network |
| 6 | CASE session establishment failed |
| 13 | matd absent / unreachable (mat listen only) |
| 1 | other |
When commission tries more than one route, the reported kind and exit code are
those of the last route attempted, and detail lists every route's result.
The native backend maps its own transport/IM outcomes onto 3 / 4 / 5 /
6; anything it cannot classify is exit 1. An operational mDNS resolve
timeout (the node did not advertise within the wait window — often
recoverable by retrying, since Thread border routers advertise on a ~30s
cycle) is timeout (exit 3); any other resolve failure (socket I/O, etc.)
is unreachable (exit 5).
kind values (stable; callers may branch on these strings):
store_missing/store_parse— credential store missing / corrupt (exit 10).store_missingtypically means you have not runmat fabric inityet.node_not_commissioned— node_id not in the store (exit 11)timeout(exit 3) /device_rejected(exit 4) /unreachable(exit 5) — classified from the native transport / IM resultsession_failed— IP reachable but CASE (operational secure session) could not be established, e.g. an intermittentCHIP Error 0x54 (Invalid CASE parameter)during the Sigma exchange (exit 6). Distinct fromunreachable(no IP route) anddevice_rejected(the device answered and refused); typically retryable.commission_failed— commissioning failed (unclassified residue, exit 1). Since 1.0.0 timeouts during PASE/CASE map totimeoutand explicit device refusals (wrong passcode / StatusReport rejection / bad Sigma2 signature) map todevice_rejectedinstead of landing here.parse_error— this kind is returned when a genericwrite/invokenames a known attribute or command field whose type islist/struct/float(not supported by the scalar-only JSON→TLV encoder — rejected up front), or names a cluster / attribute / command the generated table does not know (pass the numeric id instead).matd_unavailable(exit 13) —matdwas not reachable or died mid-request. Format listen: no socket, connection refused,MAT_MATD=0, or the connection was cut partway through the event stream (mat listenhas no direct-path fallback). Since 1.0.0 also for every other op on the matd path: forced--matdfailing to connect, or an I/O failure / silent disconnect after the request line was sent (the request may or may not have been executed — the detail says so; there is deliberately no direct-path retry, to avoid double execution of writes). Distinct fromtimeout(exit 3), whichmat listenuses only for "connected fine, zero events arrived before--timeout-ms."other— anything else (exit 1); also what agroup provisionKVS write returns once the write is attempted and fails — including a duplicate bind (detailsaysuse --rebind) or the KVS being locked by a concurrent writer (flockWouldBlock). These are hard errors (the KVS may already be touched), distinct from an unresolvable KVS, which surfaces asstore_missing/store_parse. Ambiguous interface autodetect is alsoother.child_not_found(exit 12) /child_failed(exit 1) — not emitted as top-level errors since 0.22.0 (they classified chip-tool spawn/exit failures, now removed). The variants and exit-code mapping are kept only for wire compatibility with responses from oldermat/matd. (matstill constructschild_not_foundinternally to record a missingping6as atool_missingentry insidediag node --deep'sunavailablearray — this never becomes exit 12.)
mat and matd write diagnostics to stderr as structured tracing logs
(stdout stays pure JSON). The filter comes from MAT_LOG, falling back to
RUST_LOG; the default is warn for mat and info for matd. An empty
value counts as unset, so MAT_LOG= falls back to the default instead of
silencing everything.
matd never emits ANSI escapes, and mat colors only when stderr is a
terminal — so grep node_id=42 works on a journal or through a pipe.
matd logs one line per op:
| line | level | when |
|---|---|---|
matd op failed |
warn | the path itself failed — timeout / unreachable / session_failed / other / commission_failed / matd_unavailable (plus the retired child_* kinds). Carries kind and detail. |
matd op rejected |
info | the request or its meaning was refused — store_missing / store_parse / node_not_commissioned / device_rejected / parse_error |
matd op slow |
info | success that took ≥ 300 ms. A warm session is normally 71–149 ms, so this is the early sign of a weak link or a degraded mesh. |
matd op ok |
debug | ordinary success — not shown at the default level |
Fields: op, node_id / group_id, endpoint, path (cluster/attribute
or cluster/command), elapsed_ms (the op itself, excluding JSON handling).
Absent fields are omitted rather than printed as None. String values are
quoted by the formatter, numbers are not:
WARN matd::server: matd op failed op="read" node_id=42 endpoint=1 path="occupancysensing/occupancy" elapsed_ms=8134 kind=Timeout detail=no acknowledgement within MRP retry budget
Note that kind prints the Rust variant name, not the JSON spelling — the log says kind=Timeout where the error object says "kind": "timeout".
Related lines:
no warm session; establishing(info) — a CASE session had to be built for this op. Repeatedly for one node means session churn.listen client attached/listen client detached(info) — an event-stream client connected or went away.detachedcarriesdeliveredandreason(client_disconnected/channel_closed).subscription established/report pump ended/subscription lost; resubscribing(info) — the resident Subscribe lifecycle (see Subscriptions).
journalctl -p warning gives you just the degradation.
mat's backend is a native, from-scratch Rust Matter controller (crate
mat-controller, driven through the shared mat-native engine) — TLV, CASE,
IM, groupcast, mDNS, and commissioning (on-network + BLE+Thread) are all
in-process. There is no chip-tool (or any external controller) subprocess.
- Route selection is per-op: matd auto-discovery (if a
matdanswers the probed socket) →mat's own native direct path. See Routing throughmatdand Native backend internals for interface autodetect (MAT_IFACE/MAT_MATD_IFACEoverride), fabric index, warm vs one-shot sessions, the shared groupcast counter, epoch adoption, and the scalar-only generic write/invoke rule. - First-fabric bootstrap is
mat fabric init(random-epoch IPK); see that section.
Environment variables:
| variable | purpose |
|---|---|
MAT_STORE |
credential store path (see resolution order above) |
MAT_IFACE |
override interface autodetect for mat's direct path |
MAT_MATD_IFACE |
override interface autodetect for matd |
MAT_FABRIC_INDEX / MAT_ISSUER_INDEX |
mat KVS fabric-table / CA-issuer index (default 1 / 0) |
MAT_MATD_FABRIC_INDEX / MAT_MATD_ISSUER_INDEX |
same for matd |
MAT_MATD / MAT_MATD_SOCKET |
force / opt out of the matd path; pin its socket |
MAT_PAA_TRUST_STORE |
directory of PAA root certs for attestation |
MAT_CD_SIGNER_STORE |
CD signer trust store (warn-only if absent) |
MAT_THREAD_DATASET |
Thread active operational dataset (hex) for BLE+Thread commission |
MAT_TRANSPORT |
commission route: auto (default) / on-network / ble |
MAT_PING6_BIN |
override the ping6 binary used by diag node --deep |
MAT_LOG |
tracing filter for stderr logs (e.g. info); empty counts as unset — see Logs |
Matter uses mDNS / IPv6 multicast, so running in Docker requires host networking (
docker run --network host). A bridge network cannot receive the responses.
Tasks are defined with Task (task lists them).
task build # release build -> target/release/{mat,matd}
task install # install both binaries into ~/.cargo/bin
task run -- discover # run (native backend)
task test # tests (native FakeConn + binary integration; no real devices)
task clippy # lint (-D warnings)
task fmt # format
task check # CI equivalent (fmt:check + clippy + test)
task dist:arm64 # aarch64-gnu + BLE deploy build -> dist/arm64/{mat,matd}
task docker:build # slim x86_64 image (mat/matd only)
task docker:run -- discover
task docker:test # no local toolchain neededCI (GitHub Actions, .github/workflows/ci.yml) runs the same fmt / clippy /
test sequence as task check. The default build and CI do not use the ble
cargo feature; deploy builds (task dist:arm64) enable it for BLE+Thread
commissioning.
In practice the main path is multi-admin join: adding a device that is
already commissioned by another admin (such as Home Assistant) to mat as well.
The printed code does not work (the device left commissioning mode), so the
existing admin opens a commissioning window to issue a one-time code.
- Share from the other admin: on the other controller, run "Share" for the
target device and note the issued setup code (
MT:...or 11-digit). - Join with
mat:It returnsmat commission --target <device-ip-or-host> --setup-code "<issued setup code>" --node 5
{ "node_id": 5, "status": "success" }and records the ledger in~/.config/mat/nodes.json. - Confirm:
mat discovernow shows node 5 with"state": "commissioned".
For a factory-reset device, pass the printed setup code directly to
commission(first commission).
Against a commissioned node (node 5 above), confirm read / describe / on / off on a real device.
# Introspect what you can call (endpoints and numeric cluster IDs)
mat describe --node 5
# Read the OnOff attribute (for a light, its current on/off state)
mat read --node 5 --cluster onoff --attribute on-off
# Turn on -> off (invoke of the OnOff command, not an attribute write)
mat on --node 5
mat off --node 5
# Read-after-write check (confirm the value took effect)
mat on --node 5 && mat read --node 5 --cluster onoff --attribute on-off # -> "value": trueShare mat-owned node 5 with another controller.
# Open a commissioning window (get the issued code)
mat open-window --node 5 --timeout 300
# -> { "node_id": 5, "manual_code": "...", "qr_payload": "MT:...", "expires_at": "..." }Enter the returned manual_code (11-digit) or qr_payload (render the QR with
the receiving tool) into the other controller's "Add device" flow (Alexa / Apple
Home / Google Home). Finish before expires_at. After sharing, mat keeps its
fabric membership (multi-admin).
Each one-shot run pays mDNS resolution plus a CASE handshake, so a single call is slow (hundreds of ms to seconds). Speed-sensitive use cases run
matd, which keeps warm sessions (see ARCHITECTURE.md).
With several commissioned lights (say nodes 5, 6, 7), burn a wire group and fire one multicast send at it.
# Provision the group onto every node (controller-side state is set up too)
mat group provision --group 1 --nodes 5 6 7 --name living
# -> { "group_id": 1, "keyset_id": 42, "nodes": [5,6,7], "status": "provisioned", ... }
# One multicast send — all three should react together (no popcorn effect)
mat group invoke --group 1 --cluster onoff --command on
mat group invoke --group 1 --cluster onoff --command offGroupcast is unacknowledged, so
group invokeonly confirms the send, not delivery. If a light did not react, confirm it individually (mat read --node 6 -c onoff --attribute on-off) and re-provision that node. Multicast is especially weak on Thread; Wi-Fi / Ethernet lights are more reliable. The KVS recordsmatwrites (keyset table, group table, GroupKeyMap) follow the connectedhomeip v1.4.2.0GroupDataProviderImpllink discipline, so a realchip-toolon the same store can still read them — if a devices-side provisioning step regresses, the group-settings writer is the first place to check.If no device reacts although provision reported success, suspect the device ACL first: provisions made before the ACL step (or through an old
matd≤ 0.12) never granted the group permission, and devices silently drop unauthorized groupcast.mat group grant --group 1 --nodes 5 6 7adds the missing entries idempotently.
Issues and pull requests are welcome. Before sending a PR, run task check
(format check + clippy with -D warnings + tests); it needs no real devices.
Please keep stdout pure JSON and follow the design rules in
ARCHITECTURE.md.
MIT.