mat
mat is a command-line Matter controller built for scripts and AI agents:
a from-scratch, pure-Rust Matter stack that runs in-process and prints
exactly one JSON object per command on stdout.
This site is the command and configuration reference. For the project overview, Quickstart, and design background, see the GitHub repository.
- Commands — every command with its JSON output
- Configuration — the credential store,
aliases.toml,subscriptions.toml - Errors and exit codes — error schema,
kindtable, exit codes, stderr logs - Backend — the native backend, interface auto-detection, environment variables
- Development — build / test tasks, manual E2E
Commands
Discover and commissioning
# 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 --setup-code "MT:Y.K9042C00KA0648G00" --node 5
Passing the setup code and Thread dataset safely
The setup code (QR payload or manual code) contains the device’s setup passcode,
and --thread-dataset carries the Thread network credentials (network key
included). Command-line arguments are visible to every local user in the process
list (ps, /proc/<pid>/cmdline) while mat runs, so on a shared host pass both
through the environment instead:
# MAT_SETUP_CODE / MAT_THREAD_DATASET are read when the flag is absent;
# an explicit --setup-code / --thread-dataset wins over the env var.
MAT_SETUP_CODE="MT:Y.K9042C00KA0648G00" mat commission --node 5
The setup code stays required — from the flag or MAT_SETUP_CODE; with neither,
commission is a usage error (exit 2). --help names both env vars but never
prints their values.
Route selection (--transport)
commission 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 --setup-code "MT:Y.K9042C00KA0648G00" --transport ble
discover 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, "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 (absent if the instance advertises no addresses — announce-only).reachable: false— not advertising; noaddress(the ledger stores none — addresses are resolved live, never persisted).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, "commissioned_at": "2026-06-06T12:00:00+09:00", "reachable": false },
{ "state": "commissioned", "node_id": 9, "commissioned_at": "2026-06-06T12:00:00+09:00", "reachable": null }
]
}
Without --probe the output is unchanged (no reachable); 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" }
First-fabric bootstrap (fabric init)
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. - The store is groupcast-ready from the start: the global group data counter
(
g/gdc) is seeded with a random spec-range value, the same discipline the upstream SDK uses on first boot. (A missingg/gdcmakesmatrefuse group sends rather than start the counter low.) - 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”).
List what the store already holds with mat fabric list — a local read of the
KVS (no network, no key material on stdout):
mat fabric list
{
"timestamp": "2026-06-06T12:34:56+09:00",
"store": "/home/you/.config/mat",
"fabrics": [
{ "fabric_index": 1, "fabric_id": 1, "admin_node_id": 112233,
"compressed_fabric_id": "AAAAAAAAAAAAAAAA", "ipk_epoch": "mat",
"ipk_rotation_pending": false, "current": true }
]
}
ipk_epochsays where the fabric’s IPK came from:"mat"for a fabric bootstrapped byfabric init(random epoch, recorded asmat/f/<idx>/ipk-epoch),"chip-tool"for a fabric created bychip-toolwhose fixed epochmatverified and adopted.ipk_rotation_pendingistruewhile afabric rotate-ipkis pending (new epoch persisted, controller not yet switched — see IPK rotation below);rotate-ipk --abortclears it.currentmarks the index this invocation would use for device ops (--fabric-index/MAT_FABRIC_INDEX, default1).- Local only, like
fabric init: it is dispatched before route selection, so--matdis ignored (no daemon is contacted). A missing store INI isstore_missing(exit10) with thefabric inithint. There is nofabric show— the one line per fabric carries everything.
IPK rotation (fabric rotate-ipk)
The fabric’s IPK (Identity Protection Key, key set 0 — the key CASE uses to
hide destination identifiers, and the one AddNOC installs on every new node)
can be rotated to a fresh epoch. Direct path only (it talks to every node):
# fabric rotate-ipk [--nodes <N|ALIAS>...] [--catch-up | --abort]
mat fabric rotate-ipk # every node in the ledger
mat fabric rotate-ipk --nodes 5 6 # a subset (retry / commit without the rest)
mat fabric rotate-ipk --catch-up --nodes 7 # a node that missed a committed rotation
mat fabric rotate-ipk --abort # drop a pending rotation
{
"timestamp": "2026-06-06T12:34:56+09:00",
"fabric_index": 1,
"status": "rotated",
"nodes": [ { "node_id": 5, "status": "ok" }, { "node_id": 6, "status": "ok" } ],
"matd_reload": "reloaded",
"note": "matd_reload says whether a running matd picked up the new IPK (reloaded / not_running / failed); on failed, run `matd reload` (or restart matd) before the next rotation; nodes left out of --nodes need `mat fabric rotate-ipk --catch-up --nodes <N>`"
}
How it works, and why it is safe to interrupt at any point:
- A new epoch key is generated and persisted as pending
(
mat/f/<idx>/ipk-epoch-next) before any node is touched, so a re-run after a crash resumes with the same key. - Every node (in
--nodesorder, one at a time) getsKeySetWriteon key set 0 carrying both epochs — the current one (epoch 0) and the new one (epoch 1).matthen re-establishes CASE with the new IPK to prove the node accepts it (KeySetReadnever returns key material, so this is the only proof). A failure on one node does not stop the others. - Only when every listed node succeeded does the controller switch: key
set 0 in the store,
mat/f/<idx>/ipk-epochand-prevare rewritten in one locked transaction (status: "rotated"). Otherwise nothing changes on the controller (status: "pending", see below) — the controller still uses the old IPK and every node still accepts it. - The old epoch is not removed from the nodes: they keep
{old, new}until the next rotation overwrites key set 0 with{new, newer}. Devices check every epoch of the IPK key set when answering CASE, so a controller on either epoch keeps working, and the IPK is not a session key — one stale epoch is an accepted trade-off for a rotation that can never strand a node.
Partial failure: the stdout body lists each node ("status": "failed" with
the usual error object), and mat exits with the kind / code of the
first failed node (a stderr error names all failed nodes). Re-run with the
same nodes to retry, or with --nodes <subset> to commit without the ones that
stay unreachable — those are then one epoch behind, and once they are back:
mat fabric rotate-ipk --catch-up --nodes <N> (CASE with the previous epoch,
write {previous, current}, prove with the current one). A node that missed
two rotations cannot be caught up (the previous epoch is gone): it no
longer accepts CASE at all, so its device side cannot be cleaned up remotely.
Recovery is unpair --force (ledger-only removal — the device is unreachable,
so the device-side RemoveFabric cannot happen), a factory reset of the
device, then commission. Until it is recovered (or caught up in time), such a
node is unreachable for every op against it — on, read, a plain
unpair, all of it — and the session_failed a user sees on e.g. mat on --node <N> is this. --abort drops the pending key (nodes keep the extra
epoch harmlessly).
--nodesdefaults to every node in the ledger; explicit ids / aliases must be commissioned (node_not_commissioned, exit11).--op-timeout-msbudgets each node’s step (write + proof CASE) — exceeding it istimeoutfor that node, and the run continues.--catch-upis refused while a rotation is pending (other): finish it by re-runningmat fabric rotate-ipk(same nodes, or a--nodessubset), or--abortit first.- matd loads the IPK at start-up and keeps it in memory. A committed
rotation (
status: "rotated") asks a runningmatdto reload it over the socket and reports the outcome asmatd_reload:reloaded(the daemon now holds the new IPK — no restart),not_running(no daemon answered the probed socket), orfailed(an oldmatdwithout the reload op, or the reload itself failed — runmatd reload, or restart it). The field is informational: it never changes the exit code. Until it reloads,matdkeeps working on the previous epoch (the nodes accept both) but its CASE attempts would fail after the next rotation drops that epoch. Existing warm sessions and resident subscriptions are untouched by the reload; the new IPK is used from the next CASE establishment on.commissionpicks the new epoch up immediately. The hint goes toMAT_MATD_SOCKET(or the default socket path) — with a daemon on a non-default socket (a container’s/run/matd/matd.sock, say) setMAT_MATD_SOCKETfor the rotation, or runmatd reload --socket …afterwards. Areloadedanswer that reports the IPK unchanged is logged as a warning on stderr: that daemon is serving a different store or fabric index than the one just rotated. fabric listshows"ipk_rotation_pending": truewhile a rotation is pending;fabric rotate-ipk --abortclears it.- The virtual device
matvfollows the spec here too (§11.2.8.1): it acceptsKeySetWriteon key set 0 with up to three epochs, persists them ingroup_keys.json, and answers CASE for every epoch it holds, so a rotation againstmatvcommits and survives a device restart (scripts/e2e-device-m4.shpins the success path, then the pending/abort path with the device stopped). - Never part of the
matdsocket protocol (direct-only likecommission); explicit--matdexits2.
Unpair (unpair)
The reverse of commission: mat sends RemoveFabric (its own fabric
index) to the device, and on success deletes the node from the ledger
(nodes.json) and every aliases.toml entry that pointed at it. Direct path
only — mat is the sole writer of the ledger — so auto-detection skips it and
an explicit --matd exits 2.
# unpair --node <N|ALIAS> [--force]
mat unpair --node 5
# --force: drop the ledger entry even when the device side fails (unreachable
# or rejected) — for cleaning out entries with no device behind them
mat unpair --node 15 --force
Outputs:
// success — the device left the fabric, ledger and aliases followed
{ "timestamp": "...", "node_id": 5, "aliases_removed": ["desk_light"], "device": { "removed": true, "fabric_index": 1 }, "ledger": { "removed": true } }
// --force against a dead node — the failure is folded into device.error, exit 0
{ "timestamp": "...", "node_id": 15, "aliases_removed": [], "device": { "removed": false, "error": { "kind": "unreachable", "detail": "Node 15 is unreachable" } }, "ledger": { "removed": true } }
- Without
--force, a device-side failure is a normal typed error (exit3/4/5/6) and the ledger is left untouched — nothing ends up half removed.aliases_removedlists the node aliases that were deleted (its endpoint aliases go with them); it is[]when noaliases.tomlexists. - A node that is not in the ledger is
node_not_commissioned(exit11),--forceincluded: the ledger is what says a node can be unpaired at all. --forceonly folds device-side failures — the transport / IM outcomestimeout/device_rejected/unreachable/session_failed(exit3/4/5/6). Local failures (store_missing,store_parse,parse_error, interface-autodetectother) still exit with their own code and leave the ledger alone: nothing there is evidence the device is gone.- node_id は再利用されない。 A stale SRP record for an unpaired id keeps
being advertised and CASE to it fails forever, so the ledger keeps a
high-water mark (
next_node_idinnodes.json):commissionwithout--nodealways hands out an id above every id ever issued, even after the highest one was unpaired. Ledgers written before this field existed fall back tomax(ledger) + 1on first load and then start tracking it. - With
matdrunning, its resident subscription for the removed node goes away by itself at the next ledger rescan (≤ 60 s): the subscription loop is aborted and the node drops out ofmatd status. No restart needed.unpairis also the one direct-path op that does not send thenode_touchedhint — nudgingmatdto re-establish a session with a node that just left the fabric would only produce CASE-failure backoff until that rescan.
Attestation / PAA trust store
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 --setup-code "MT:Y.K9042C00KA0648G00" --node 5
Resolution 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).
State operations
<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
# Omit --attribute to read every attribute of the cluster in one wildcard read
mat read --node 5 --cluster onoff
# Set a writable attribute
mat write --node 5 --cluster levelcontrol --attribute on-level --value 128
# List / struct values are JSON (see "Generic write / invoke value encoding")
mat write --node 5 --cluster accesscontrol --attribute acl --value '[{"privilege":5,"auth-mode":2,"subjects":[112233],"targets":null}]'
# Run a command: --command plus trailing command args
mat invoke --node 5 --cluster levelcontrol --command move-to-level 128 0 0 0
# --timed forces a Timed Request (see below). For invoke it must come before
# the trailing positional args.
mat invoke --node 5 --cluster onoff --timed --command off
mat write --node 5 --cluster onoff --attribute on-time --value 0 --timed
# 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,170
Important 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 }
// read with --attribute omitted — every attribute of the cluster, keyed by
// chip-tool attribute name (attribute ids the name table does not know appear
// as their decimal id in a string key)
{ "timestamp": "...", "node_id": 5, "endpoint": 1, "cluster": "onoff", "attributes": { "on-off": true, "global-scene-control": true, "on-time": 0, "65533": 5 } }
// 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.
Cluster wildcard read (--attribute omitted). One ReadRequest for the
whole cluster; the result is an attributes object instead of a single
value.
- Keys are chip-tool attribute names, falling back to the decimal attribute id
(as a string key) for ids the generated name table does not know — global
attributes such as
65533(ClusterRevision) show up that way. listandstructvalues are included as JSON, exactly as a single-attributereadprints them. This is also the workaround for the chunk limit: a single-attribute read of a large list fails (chunked report data unsupported) because that path takes one ReportData message, while the wildcard read collects theMoreChunkedMessagestrain and merges the per-element appends. Read the whole cluster when one attribute is too big.- Attributes the device does not implement are simply absent — a wildcard
read returns no per-attribute status, so there is nothing to report for them.
An endpoint or cluster the device does not have at all comes back as an empty
attributesobject, not an error. - Routed through
matdlike a normalread(see Routing throughmatdfor the version-skew note on older daemons).
--timed on write / invoke. Sends a TimedRequest first, so the device
enforces the timed-interaction window. It is a true-only override: commands
and attributes the name table marks as timed (door locks, window coverings, …)
are always timed and the flag cannot switch that off. Use it for numeric ids
and for entries the table has as untimed but the device insists on timing.
On invoke it must be written before the trailing command args, otherwise
clap takes it as one of them (... too many arguments). group invoke has no
--timed (groupcast has no timed request).
Diagnostics
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 native targeted mDNS + ping6 (to the live-resolved address)
{
"timestamp": "...", "node_id": 5, "endpoint": 0,
"verdict": "link_starved",
"summary": "Not advertising Matter on any fabric; weak Thread link (best LQI 3) — SRP registration likely incomplete.",
"checks": {
"mdns": { "advertised_self_fabric": false, "advertised_any_fabric": false },
"operational": { "resolved": false, "kind": "timeout" },
"thread": { "neighbor_count": 1, "best_lqi": 3, "routing_role": 2 }
},
"unavailable": [ { "check": "ip", "kind": "mdns_unresolved" } ],
"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 mDNS and ping6 evidence that distinguishes them.--deepresolves the node via a native targeted mDNS lookup first and pings the live-resolved address (the ledger stores no address — Issue #18); if the node is not advertising at all there is nothing to ping, theipcheck lands inunavailableasmdns_unresolved, and the split then rests on the Thread-side evidence. A failed mDNS probe itself (e.g. an interface I/O error) also landsipasmdns_unresolved; the two cases are distinguishable because the probe-failure case additionally reports a{"check": "mdns", ...}entry underunavailable. 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), andnode:<node_id>for a fabric node whose ExtAddress could not be determined at all (e.g. probe failure). Unknown participants get alabelfromaliases.toml‘s[thread]section (see Aliases) instead of analias, which is reserved for commissioned nodes’ own node alias.A probed fabric node whose cluster 0x33 self-identification failed (unreadable, or a duplicate self-ext claim invalidated below) is rescued deterministically before falling back to
node:<id>(issue #13). Real-device finding (2026-09-01): a router/leader’s own route table contains a row for itself, shapedNextHop= 63 (invalid) plusPathCost= 0 withAge≈ 0 (OpenThread gives routers with a lost route the same 63/0 shape, so an aged 63/0 row is not treated as a self-row, and 63/0 rows are never ingested as participants or edges). Vendor-dependently the self-row carries either the node’s real ExtAddress (used directly; the node gets"identified_by": "route-table") orExtAddress: 0with the node’s own Rloc16. A self Rloc16 — from the self-row, or derived from cluster 0x33 IPv6Addresses + mesh-local-prefix (this derivation is independent of ExtAddress canonicalization) — is then matched against every probed node’s neighbor/route table observations (real ext + Rloc16): exactly one observed ext, not already claimed by another fabric node, merges the two vertices into one ("identified_by": "rloc16"). Anything ambiguous is rejected — several exts observed under that Rloc16, a resolution colliding with an already-identified ext or with another rescued node, or a mesh currently split into several partitions (Rloc16 uniqueness only holds within one partition, so the whole correlation is skipped whilepartition_idshas more than one entry).matnever merges by guesswork: neighbor-set similarity heuristics were evaluated on the real mesh and mis-merge dense routers into the border router, so they are deliberately not used. Note the rescue trusts the freshest tables available in one snapshot; a Rloc16 reassigned moments before the probe while every peer’s table is still stale can in principle mislabel a node for one run —identified_bymarks such merges as evidence-based rather than 0x33-claimed, and the next run self-heals.A fabric node that still could not self-identify (probe failed outright, or every rescue path was rejected as ambiguous) shows up as a
node:<id>graph node without arloc16— 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.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 — the bogus claim is discarded for all of them (the rescue above then usually recovers each node’s real ext from its route-table self-row). 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: 0that is not the self-row is treated as garbage and ignored (no node, no edge — REEDs list far routers this way).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).
Listen (device-originated events)
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>]
[--event [<name>]] [--count <N>] [--timeout-ms <T>] [--reconnect]
- 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).--event [<name>]selects device-originated event lines instead of attribute-change lines (bare--event= every event name);--attributeand--eventare mutually exclusive. --count(default1) is how many lines to receive before exiting0;0means no count limit — keep streaming (symmetric with--timeout-ms 0). Both kinds of line count: without--attribute/--eventthe stream carries attribute-change lines and device-originated event lines, so a defaultmat listen --count 1returns whichever arrives first. Pass--attributeor--eventwhen the count must be of one kind.--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, reads (and discards) the ack line, then prints one JSON event per line to stdout as they arrive:{"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 with a debug log. This is a listen-only limitation (genericreadandwritehandle lists and structs as JSON): the resident Subscribe processes each ReportData message on its own and does not reassemble chunked lists (MoreChunkedMessagesplus per-elementListIndex: nullappends), so a list event could only ever carry a partial value, and the priming-diff recovery would misread per-element appends as transitions. Consumers that need a list value read it on demand.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. - Event lines (from
--event [<name>], or a defaultmat listenwith no filter) carry aneventkey instead ofattribute— that is the field an existing consumer should switch on to tell the two kinds of line apart. Amat listenwith no--attribute/--eventnow emits both kinds of line interleaved in receive order; an oldmatclient (pre-phase-B) and a new defaultmat listenboth keep working unchanged on the attribute side and now also see event lines mixed in, so a consumer written against the old attribute-only contract (e.g. casa) needs a one-line change: skip or branch on lines that have noattributekey. Three examples — the first two are Generic Switch presses (spec shape), the third is abooleanstatestate-changea real device sent during the phase B real-device smoke (node id and timestamp shown as example values):
Keys:{"timestamp":"2026-09-06T21:00:00+09:00","node_id":25,"endpoint":2,"cluster":"switch","event":"initial-press","event_number":1725600000123,"priority":"info","data":{"new-position":1},"priming":false} {"timestamp":"2026-09-06T21:00:01+09:00","node_id":25,"endpoint":2,"cluster":"switch","event":"multi-press-complete","event_number":1725600000130,"priority":"info","data":{"previous-position":1,"total-number-of-presses-counted":2},"priming":false} {"timestamp":"2026-09-07T00:37:33+09:00","node_id":25,"endpoint":1,"cluster":"booleanstate","event":"state-change","event_number":590151,"priority":"info","data":{"state-value":false},"device_time":{"system_ms":577001426},"priming":true}timestamp(receive time — same contract as attribute lines from the same ReportData),node_id,endpoint,cluster(name frommat-core::ids, numeric if the table doesn’t know it),event(name, numeric if unknown),event_number,priority("debug" | "info" | "critical"),data(object keyed by kebab event-field names from the ids table; a field the table doesn’t know is keyed by its decimal context tag; the key is omitted entirely when the event carries no data),device_time({"system_ms": N}or{"epoch_ms": N}, omitted when the device sent a Delta timestampmatdcouldn’t resolve),priming. There is norecoveredkey on event lines — events are addressed byEventNumber, so a transition missed during a blind window is retrieved exactly (see the priming rule below), not inferred from a value diff the way attributerecoveredis. - Bare
--eventmatches every event name;--event <name>needs--clusterunless<name>is numeric — the same rule as--attribute.--attributeand--eventtogether isparse_error(exit2): a singlelistenstream is either attribute-filtered, event-filtered, or (with neither flag) unfiltered on both. - Event priming rule.
matdkeeps the lastEventNumberit has seen per node in process memory only (lost on amatdrestart — design rule 4). Atmatdstart there is noEventFilteryet, so the very first Subscribe pulls the device’s whole event log and every one of those events arrives withpriming: true(a consumer ignores them, same discipline as attribute priming). On a later re-subscribe within the samematdprocess (a dropped Thread link, eviction by another CASE session, …),matdsetsEventMin = last + 1in the new subscription’sEventFilters: events at or above that number are real transitions that happened during the blind window and arrive withpriming: false— the event-side replacement for the attribute side’srecoveredheuristic, exact by EventNumber instead of inferred from a value diff. A non-compliant device that replays events belowEventMinanyway has those downgraded back topriming: true(the blind-window contract holds even if the device ignores the filter). Events within one ReportData fan out in ascendingevent_numberorder, with the sametimestampas any attribute lines from that same ReportData. 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.--reconnectturns those three losses (connect failure, EOF, a non-JSON line) into a retry instead of exit13, so a long-running consumer survives amatdrestart — and can be started beforematdis up. Backoff is 1s → 2s → 4s → … capped at 30s and reset on a successful connect; the attempts are logged to stderr (warnon loss,infoon reconnect). No marker line is written to stdout — the contract stays one JSON event per line.--countaccumulates across reconnects and--timeout-msis a single overall deadline that also covers the waiting, so--count 5 --timeout-ms 60000 --reconnectmeans “5 events within a minute, restarts included” (deadline with zero events still exits3). Note that the priming burst after each reconnect counts toward--countlike any other event — key off theprimingfield if you only want real transitions. Anerrorlinematdsends inline (e.g. the slow-consumer lag disconnect) still terminatesmat listen;--reconnectonly covers losing the daemon.- 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.
Multi-admin share
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 300
Output:
{ "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). --iterationmust be in1000..=100000(spec §3.9) and--discriminatorwithin 12 bits (0..=4095). Out-of-range values fail fast withparse_errorbefore any invoke is sent.- “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.
Groupcast
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 | --epoch-key-file PATH]
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 7
# List the controller-side group state (local KVS read, no network)
mat group list
# Remove (the reverse of provision): ACL entry / RemoveGroup / group-key-map
# row / unreferenced KeySet on every node, then the controller-side state.
# remove --group <ID|ALIAS> --nodes <N|ALIAS>... [--endpoint EP] (EP default 1)
mat group remove --group 1 --nodes 5 6 7
Outputs:
// provision — all listed nodes succeeded (provision stops at the first failure).
// The same shape on the direct path and through matd: matd re-reads the
// group's credentials from the KVS on every send, so no note, reload or
// restart follows a provision.
{ "timestamp": "...", "group_id": 1, "keyset_id": 42, "name": "living", "endpoint": 1, "nodes": [5, 6, 7], "status": "provisioned" }
// 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" }
// list — the controller's own group table; key material is never included
{ "timestamp": "...", "fabric_index": 1, "groups": [ { "group_id": 1, "name": "living", "keyset_id": 42 } ], "keysets": [ { "keyset_id": 42, "bound_groups": [1] } ] }
// remove — per-node teardown result plus the controller-side removal
{ "timestamp": "...", "group_id": 1, "endpoint": 1, "nodes": [ { "node_id": 5, "acl_removed": true, "group_removed": true, "keymap_removed": true, "keyset_removed": true } ], "controller": { "group_removed": true, "keyset_removed": true }, "status": "removed" }
- 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). To pass a fixed key, prefer--epoch-key-file <PATH>(a file holding the 32 hex chars; surrounding whitespace is ignored;-reads stdin): a flag value is visible in the process list to other local users, a file or pipe is not. The two flags are mutually exclusive, and an unreadable or empty file is anothererror — it never falls back to a random key. There is deliberately no environment variable for this key: a value left exported would be silently reused by the next, unrelatedgroup provision.--keyset-iddefaults to 42,--nametogrp<group_id>,--endpointto 1. Keyset 0 is reserved for the IPK and is rejected (parse_error, exit 1) before anything is written — provisioning it would overwrite the fabric’s IPK on every node and in the controller KVS. Usemat fabric rotate-ipkinstead.- 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. A direct-path--rebindneeds no follow-up onmatd: it re-reads the group’s operational credentials from the KVS on every send (see “Pick one group sender” below). mat group listshows the controller side only. It walks the group / key-map / keyset chains in the credential KVS and prints them; it never talks to a device, never touches the network, and--matdis ignored (it is dispatched before route selection, likefabric init). Epoch keys, operational keys and GKHs are deliberately not in the output — they are credentials.groups[].keyset_idisnullfor a group with no key-map row, andkeysets[].bound_groupsis the reverse mapping. A fabric that was never provisioned prints empty arrays and exits0; a broken chain isstore_parse.mat group removeis the reverse of provision, direct path only (likegrant: a rare repair/teardown op whose controller-side state onlymatowns, so--matdexits2). Per node it strips the ACL Group entry, sendsRemoveGroup, drops the group’sgroup-key-maprow and — only if no remaining row on that device still references it — the KeySet; then, once every node has succeeded, it removes the group (and any now-unreferenced keyset) from the controller KVS.--nodesis required: the controller-side state holds no membership list, somatcannot know which devices to visit. Pass every member.- It stops at the first failing node and the controller KVS is left
untouched in that case — nodes already processed keep their device-side
teardown, so the recovery is to fix that node and re-run with the nodes
that are left (each step is idempotent: an already-removed group comes back
as
NOT_FOUNDand is reported asgroup_removed: false, not an error). - If the controller KVS no longer holds the group when the devices are
already torn down (a re-run after a partial failure, or a teardown someone
else finished), that is not an error:
controller.group_removedcomes backfalseand the exit code stays0. Failing there would only make a finished teardown look unfinished. - The IPK keyset (
keyset-id 0) is never unlinked from the controller KVS, even if the removed group was the only thing bound to it — it carries the fabric’s operational key material, not group-only state. - The ACL step never writes when the read cannot be parsed — same safety rule as provision (a blind ACL write could drop the admin entry).
mat group listafterwards is the check that the controller side is clean.
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).
mat group bump — jump the group counter window (first aid)
If some devices silently drop groupcast (unicast fine, group settings
identical to a working peer), their replay window may have run ahead of the
controller’s send counter. mat group bump jumps the counter forward by one
matd-restart-equivalent window — the same remedy a matd restart applied,
without dropping warm sessions or resident subscriptions.
mat group bump
{"timestamp":"...","group_counter":{"from":176561405,"to":176569504}}
The counter is fabric-global (one series for all groups), so there is no
--group argument. Routed like other group ops: via matd when one is
running, else directly (a direct run while matd holds the counter lock
fails with store_parse — use the matd route).
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 100
Routing through matd
Each 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) — the first default mat probes below. Without
# XDG_RUNTIME_DIR there is no default: pass --socket <path> (in a directory
# only you can write to). The socket is chmod'ed 0600 right after bind.
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/use
Stop 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.sock
Make a running daemon pick up a rotated IPK with matd reload (what
mat fabric rotate-ipk sends automatically on commit — see matd_reload
there). It re-reads the fabric credentials from the KVS and swaps them into
the CASE establisher; warm sessions and resident subscriptions stay up, and
the new IPK applies from the next establishment on. Group credentials are not
part of it (they are read on every send). A store whose fabric identity
changed (fabric id, node id or root key) is refused with other — restart
instead:
matd reload # default socket
matd reload --socket /run/mat/matd.sock
{"timestamp": "2026-06-03T12:34:56+09:00", "reloaded": true, "ipk": "changed", "reload_count": 1}
ipk is changed or unchanged (the key itself is never printed). If the
native backend failed to build at startup, reload returns that error like
every other op (the daemon must be restarted once the store is fixed).
Errors follow mat’s contract, not stdout: only the success body above is
printed to stdout, while {"error":{"kind","detail"}} goes to stderr and the
exit code is the kind’s (a changed fabric identity is other, exit 1; a
store that went missing or unreadable is store_missing / store_parse, exit
10). With no daemon answering the socket, matd reload reports matd not running at … and exits 1 — the same as matd status and matd stop.
Ask the running daemon what it is doing with matd status — one JSON line on
stdout with daemon basics and the per-node state of the resident subscriptions
(the same lifecycle the logs narrate: establishing → established →
down with backoff and the last error). Durations are all “seconds ago”
fields; subscribed_clusters mirrors subscriptions.toml (null = full
wildcard); pending_op_ago_s is non-null only while a state-changing op has
gone unanswered by the device (the op-correlation window):
matd status # default socket
matd status --socket /run/mat/matd.sock
{
"timestamp": "2026-06-03T12:34:56+09:00",
"version": "1.12.0",
"uptime_s": 86400,
"native": "ready",
"iface": "wpan0",
"fabric_index": 1,
"store": "/home/user/.config/mat",
"subscribed_clusters": ["onoff", "occupancysensing"],
"reloads": {"count": 1, "last_at": "2026-06-03T12:00:00+09:00"},
"listen_clients": 1,
"nodes": [
{"node_id": 5, "state": "established", "for_s": 3600,
"subscription_id": 7, "max_interval_s": 300,
"last_device_msg_ago_s": 42, "pending_op_ago_s": null},
{"node_id": 6, "state": "down", "for_s": 120, "attempts": 14,
"backoff_s": 60, "last_error": {"kind": "unreachable", "detail": "..."}}
]
}
reloads counts successful matd reloads since start-up (last_at is null before the first).
If the native backend failed to build at startup, native carries that error
({"kind": "store_missing", ...}) and nodes is empty. If no daemon answers
the socket, matd status exits 1 with matd not running at ... (same
contract as matd stop).
Only 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). Stale sockets fail the connect and fall through naturally. - No default without
XDG_RUNTIME_DIR(unset, empty, or not an absolute path). There used to be a/tmp/matd.sockfallback; it is gone because in a world-writable/tmpanother local user could plant a fake socket there whilematdis down andmatwould send it ops and trust its answers. Now there are zero default candidates: auto-detect behaves exactly as “no matd running” (direct path), forced--matdandmat listenarematd_unavailable(exit13, immediately —listen --reconnectdoes not wait for a socket that cannot appear), andmatditself (serve /stop/status/reload) refuses withother(exit1) asking for--socketorXDG_RUNTIME_DIR. Give an explicit socket with--matd <path>/MAT_MATD_SOCKETon thematside and--socketon thematdside. - Access control is the socket’s file mode. Anyone who can connect can run
every op (write / invoke /
shutdown/reloadincluded).matdsets the socket to0600right after bind (independent of umask) and creates<socket>.lockas0600; the default socket directory is created0700. Keep an explicit--socketin a directory only the daemon’s user can write to. - Request line cap. One request line may be at most 1 MiB (legitimate
requests are small JSON). A longer line gets a single
parse_errorline back and the connection is closed —matdnever buffers past the cap, and other connections are unaffected. - 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(the cluster-wildcard form too) /write/invoke/on/off/color-temp/color/level/describe/group(provision/invoke/color-temp/color/level/bump;group grantandgroup removeare direct only — see Groupcast above).discover/commission/unpair/fabric init/open-window/diag/fabric rotate-ipkare direct-only: auto-detection skips them silently; explicit--matdexits2.fabric listandgroup listare neither: they are pure local KVS reads dispatched before route selection, so no daemon is contacted and--matdis simply ignored (not exit2).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). - Version skew: a new
matagainst an oldermatd(≤ 1.30.0). Two of the additions extend the socket protocol, so an older daemon does not understand them. A cluster-wildcardread(--attributeomitted) is rejected by the oldmatdwithparse_error(exit1) for the missingattributefield, and--timedonwrite/invokeis silently dropped (an unknown field is ignored, so the op runs untimed). Upgradingmatdfixes both; until then run those calls on the direct path:
The reverse direction is safe: an olderMAT_MATD=0 mat read --node 5 --cluster onoff MAT_MATD=0 mat invoke --node 5 --cluster onoff --timed --command offmatagainst a newmatdsends neither field and behaves exactly as before. - Version skew:
mat listen --eventagainst an oldermatd(≤ 1.35.0). The event filter is a new key in thelistenrequest, and an old daemon ignores unknown keys — so--eventis silently dropped: attribute lines stream as before and--count 1returns an attribute line instead of an event line (no error, exit0). There is no direct-path fallback forlisten, so the fix is to upgradematdbeforemat— an oldermatagainst a newmatdkeeps working unchanged (it never sends the key). Older daemons also never subscribe toEventRequestsat all, so no event line exists to receive in the first place. - 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.
Resident Subscribe and mat listen
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 — plus every cluster’s events via
EventRequests, see below), so device-originated attribute changes
(occupancy, open/close, temperature, on-off, …) and device-originated
events (button presses, booleanstate state changes, …) 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.- Events (phase B, landed): the resident Subscribe’s
EventRequestsare always sentIsUrgent = true— non-urgent event paths would only report at the next keep-alive (≤300s), which defeats a button’s purpose, somatdnever offers that knob. Attribute and event paths narrow independently viasubscriptions.toml’sclusters/eventskeys — see Subscriptions (subscriptions.toml, optional, matd only). On the wire,Op::Listengained one optional key,"event": a name or numeric id for--event <name>, the literal"*"for a bare--event(every event), and the key omitted entirely for an attribute-only or unfilteredlisten— an oldmatclient never sends it and only ever receives attribute lines, as before. Emitted lines fan out through the sametokio::sync::broadcastchannel as attribute events, carrying either variant ofmatd’s internalEmittedenum (Emitted::Attribute/Emitted::Event) — one channel, one lagged-consumer disconnect rule, for both kinds of line. See Listen above for the event-line JSON shape and theEventMinpriming rule. Not yet implemented (tracked as future work): aDataVersionFilterand LIT ICD check-in registration.
Native backend internals
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.
Interface selection
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.
Op timeout budget (--op-timeout-ms)
The global --op-timeout-ms (env MAT_OP_TIMEOUT_MS, default 60000, 0 =
unlimited) bounds how long a single-node op (read / write / invoke /
on / off / color / color-temp / level / describe) may run, on
either path. It is unrelated to mat listen’s --timeout-ms (that one bounds
the event stream’s receive wait, not a single op).
- matd path: the budget rides the request as
deadline_ms(a relative ms value).matdenforces it and returns a structuredtimeout(exit3) once the budget is spent.mat’s own socket read is given a backstop timeout of budget + 2s, so an oldmatd(which ignoresdeadline_ms) that is alive but stuck — hung, or just slow to answer — still surfaces astimeoutinstead of hanging forever; the detail notes the request may already have executed. Amatdthat dies mid-request closes the socket instead, whichmatsees as EOF and reports asmatd_unavailable(exit13), same as any other mid-stream matd loss. - Direct path: the same budget wraps the whole op in a
tokio::time::timeout; exceeding it istimeout(exit3) too, so the flag behaves the same regardless of which path answers. - Multiple resolved addresses (either path): CASE is raced across the
candidates Happy-Eyeballs style — each candidate gets its own UDP socket,
attempts start 500 ms apart (non-link-local addresses first), the first
successful handshake wins and the rest are dropped. A dead first address
therefore costs ~0.5 s instead of the full MRP budget (~80 s at
SII=5000). The race as a whole still runs under the op budget; a node whose only address is unreachable still ends intimeoutwhen the budget is spent. On total failure thesession_faileddetail lists every address tried. - A request that omits
deadline_ms(an oldmattalking to a newmatd) getsmatd’s own default budget of 60s applied — the same number--op-timeout-msdefaults to, so old and new clients see the same behavior without needing to agree on anything. - Ops outside this list (
discover/commission/fabric init/open-window/diag/group ...) ignore--op-timeout-msentirely — unchanged, no read timeout either.
Fabric index, sessions, epoch
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. That one retry only
fires when at least 10s of the op’s budget remains (see Op timeout
budget); with less left,
matdskips the re-establish and returns the timeout immediately instead of spending the remaining budget on a retry that cannot finish before the deadline.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 value encoding
Generic write / invoke (and group invoke) encode the value from the type
the generated name table (mat-core::ids, connectedhomeip v1.4.2.0) knows for
the attribute / command field:
- Scalars use plain literals:
true/false/1/0(bool), decimal or0x..(uint / enum / bitmap), decimal (int),1.5/2e-3(float —single/doublepick TLV f32 / f64), any text (string),hex:aabb(octstr).nullwrites a null (nullability is checked by the device). - Lists and structs take JSON. A struct is an object whose keys are the
field names in kebab-case (
privilege,auth-mode,group-key-set-id) or the numeric field ids as strings ("1","254") — the shapereadprints — so areadresult can be written back verbatim. Inside JSON, uint fields also accept"0x.."strings (64-bit subjects), octstr fields accept"aabb"or"hex:aabb", and fabric-scoped structs accept the implicitfabric-index(254; the server ignores / replaces it). Unknown keys, a field given both by name and by id, a missing non-optional field, or a JSON shape that does not match the type areparse_errorwith the offending path indetail. - Numeric ids (a cluster / attribute / command the table does not know)
carry no schema: scalars are inferred from the literal (
true,42,-1,1.5,hex:.., else string); a list / struct JSON is aparse_error— use a name the table knows.
The value is encoded by whichever process runs the op. When a write /
invoke is routed through a running matd, the daemon does the encoding, so
a matd older than this release rejects list / struct (and float) values with
parse_error (... supports scalars only). Upgrade matd, or force the
direct path with MAT_MATD=0 for that call.
A list value must fit in a single WriteRequest; mat does not chunk list
writes (ListIndex: null append trains), which is ample for the writable
lists in practice (ACL, group-key-map, binding, labels). The value echoed
in the write result is the parsed input (JSON for list / struct, the
normalized literal otherwise), not a read-back from the device.
The group provision / grant list/struct writes (KeySetWrite, GroupKeyMap,
ACL read-modify-write) keep their dedicated encoders; unit tests pin that the
generic encoder produces byte-identical TLV for the same entries.
# Write back a list-of-struct attribute exactly as read prints it (numeric keys)
mat write --node 5 --cluster groupkeymanagement --attribute group-key-map \
--value '[{"1":257,"2":1,"254":1}]'
# ... or with field names
mat write --node 5 --cluster groupkeymanagement --attribute group-key-map \
--value '[{"group-id":257,"group-key-set-id":1}]'
Groupcast counter (shared between mat and matd)
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.
Groupcast egress (LAN + Thread TUN)
Groupcast is sent on the operational interface (the same one mDNS uses), and
— when a Thread TUN is available — also directly on that interface (MPL
injection, no dependency on another border router’s multicast relay). The
Thread interface is picked once at startup: --thread-iface /
MAT_THREAD_IFACE (MAT_MATD_THREAD_IFACE for matd) wins; otherwise, if
exactly one multicast-capable wpan* interface is up it is auto-selected;
otherwise groupcast
stays LAN-only (previous behavior). An explicitly configured interface that
fails to resolve is a hard error on mat’s one-shot direct path; for matd
it starts up regardless, but every op then fails with that error — it does
not silently degrade. An auto-detected interface that fails to resolve
instead degrades to LAN-only with a warning. The same
datagram (same counter) goes out on every egress — receivers drop the
duplicate via replay protection. The result JSON reports the interfaces
actually used: "egress": ["eth0", "wpan0"].
Thread-only egress (--group-egress thread / MAT_GROUP_EGRESS=thread,
MAT_MATD_GROUP_EGRESS for matd; default all): while a Thread TUN egress
is established, the LAN copy is not sent. The LAN copy is not free — the
Primary Border Router relays it into Thread as well, so with both egresses
every groupcast is injected into the mesh twice (measured 2026-10-10 on an
OTBR: either path alone costs the same ~8 BR frames + ~30 mesh re-floods).
Use thread when no Wi-Fi Matter device is a member of any group; it halves
mesh airtime for groupcast bursts. If no Thread egress is established yet
(matd started before the OTBR), the send still goes out on the operating
interface with a warning, and switches to Thread-only once the late egress
is acquired. egress in the result JSON always lists what was actually sent.
Ops that never route through matd
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.
Running the op direct is not the same as matd never hearing about it: any
node-targeted op that ends up establishing a CASE session on the direct
path sends a single fire-and-forget node_touched hint to matd (if one
is running) right after the op closes, so a resident subscription for that
node resubscribes immediately instead of waiting on matd’s 330s silence
deadline (Issue #20, 1.15.0; see ARCHITECTURE.md). An op cut short by
--op-timeout-ms sends the same hint when the deadline fires (Issue #22,
1.17.0): the timed-out attempt may already have established a CASE
session the device re-anchors onto, and the close itself can no longer
be sent. That covers the
always-direct ops above (open-window, diag thread / diag node / diag mesh — once per touched node — and group grant), but just as much any
normally-matd-routed op (on / off / read / write / invoke /
describe / group provision / group invoke / …) whenever it happens
to run on the direct path instead — matd unreachable, MAT_MATD=0, or
auto-detect falling back. The op itself still runs entirely on the direct
path either way — matd never executes it, only reacts afterward — and
discover / commission / fabric init send no hint at all (no CASE
session, or, for commission, no subscription yet to refresh).
fabric rotate-ipk sends a hint of its own kind on the same socket: after a
committed rotation it asks the running matd to reload its credentials and
reports the answer as matd_reload (see IPK rotation above). Unlike
node_touched it waits for the reply, but it stays informational — the
rotation’s exit code does not depend on it.
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 &
Configuration
Credential store
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).
The store holds the Root CA private key and the IPK in plaintext, so mat
writes every store file owner-only (0600, regardless of umask — each write is
a tmp+rename replace, so the mode is re-applied every time) and creates a
missing store directory as 0700. An existing directory’s mode is left as is
(e.g. a mounted Docker volume) — make sure it is not world-readable.
matd socket
matd binds $XDG_RUNTIME_DIR/matd/matd.sock by default (directory created
0700) and mat probes that path (then the pre-0.27.0
$XDG_RUNTIME_DIR/matd.sock). Without a usable XDG_RUNTIME_DIR (unset,
empty, or relative) there is no default — the old /tmp/matd.sock
fallback was removed because another local user could pre-create a fake socket
in the shared /tmp. Then matd needs --socket <path>, and mat finds no
daemon unless given --matd <path> / MAT_MATD_SOCKET (auto-detect simply
uses the direct path). The socket is set to 0600 after bind and
<socket>.lock is created 0600: the file mode is the only access control,
so put an explicit socket in a directory only that user can write to. See
Routing through matd.
Aliases (aliases.toml, optional)
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/--groupingroup provision/invoke/grant/color-temp/color/level(bumptakes none — the counter is fabric-global).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 5
Resolution 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 --setup-code "MT:Y.K9042C00KA0648G00" --node 5 --alias living-light
The 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.
Subscriptions (subscriptions.toml, optional, matd only)
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 listen’s attribute lines only ever come from the listed clusters — a--clusterfilter naming a cluster outside that set simply never matches anything on the attribute side. (This narrows attribute paths only; theeventskey below narrows event paths independently — see there for what “no event lines outside the list” means on the event side.) - 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.
events — event subscription scope
events is independent of clusters above: clusters narrows the
attribute paths of the resident Subscribe, events controls which
clusters’ events (EventRequests) it asks for. Either key can be present
without the other, and a file with only one of them is valid.
# All three forms are independent of `clusters`.
events = ["switch", "booleanstate"] # only these clusters' events
events = [] # no EventRequests at all
# events key omitted entirely -> all clusters' events (wildcard)
- Key absent — every cluster’s events, urgent priority (the same
wildcard discipline as an absent
subscriptions.toml). events = [...]— only the listed clusters’ events, urgent priority. Same name/numeric resolution asclusters(chip-tool notation or"0x0006"/"6", unknown names arestore_parse).events = []— noEventRequestsat all: the resident Subscribe goes out exactly as it did before event subscription support (Phase A), i.e. attribute reports only.- All event paths are sent
IsUrgent = trueregardless of which of the three forms is in effect — there is no non-urgent option (see Resident Subscribe andmat listenfor why). - Rollout / canary. Order matters: update the
mat listenconsumers first, then enableevents. Even the narrow canary list putsbooleanstatestate-changelines (andswitchpresses) into every unfilteredmat listenstream, and those lines count against--count, so a consumer doingmat listen --count 1can get an event line where it used to get an attribute line. Give each consumer either a branch on theattribute/eventkey or an explicit--attributeflag before turning the key on. Then start productionmatdwithevents = ["switch", "booleanstate"]rather than jumping straight to wildcard: watch priming time across all nodes and stderr for any device rejecting theEventRequestspath —matdlogs onewarnper report withrejected(count) plus the first rejected endpoint/cluster/event/status, andINVALID_ACTIONduring establish/priming — then widen by removing theeventskey entirely once that looks clean.events = []is the instant off switch if the widen step goes wrong. - Upgrade caveat — absent means wildcard. Upgrading
matdto a phase-B-or-later build with an existingsubscriptions.tomlthat has noeventskey turns on wildcard event subscriptions for every commissioned node the momentmatdrestarts (the same absent-file default asclusters, but new behavior the day this key first exists). If that is not what you want on day one, add an explicitevents = [...](orevents = []) line tosubscriptions.tomlbefore deploying the upgrade, following the canary path above.
Errors and exit codes
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 (includes a single-node op exceeding its --op-timeout-ms budget) |
| 4 | device rejected |
| 5 | unreachable / network |
| 6 | CASE session establishment failed |
| 13 | matd absent / unreachable (mat listen only; mat listen --reconnect retries with backoff instead of exiting — see below) |
| 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).
mat fabric rotate-ipk is the one command that can both print a body on
stdout and exit non-zero: a partial failure (status: "pending" /
"catch_up_incomplete") emits the per-node result as JSON on stdout, then
reports the first failed node’s kind on stderr (and exits with its code) so
scripts that only check the exit status still notice. stdout stays pure JSON.
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 result.timeoutis also what a single-node op returns once its budget runs out (--op-timeout-msonmat, ormatd’s own 60s default when the request carried none) — see Op timeout budget.session_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/invokevalue does not fit the type the generated table knows for the attribute / command field (bad literal, JSON of the wrong shape, unknown / missing struct field — the detail names the path), when a list / struct JSON is given with a numeric id (no schema), or when a cluster / attribute / command name is not in the table (pass the numeric id instead).matdalso answersparse_errorto a request line longer than 1 MiB, then closes that connection.matd_unavailable(exit 13) —matdwas not reachable or died mid-request. Format listen: no socket (including no socket candidate at all —XDG_RUNTIME_DIRunset and no--matd <path>/MAT_MATD_SOCKET; exits immediately even with--reconnect), connection refused,MAT_MATD=0, or the connection was cut partway through the event stream (mat listenhas no direct-path fallback). Withmat listen --reconnectthose losses (connect failure, EOF, a non-JSON line) are retried with backoff (1 s → 30 s) instead of exiting13;MAT_MATD=0still exits13immediately (the route is resolved before the stream, so there is nothing to reconnect to), and anerrorlinematdsends inline still terminates the run. 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, as ismatd(serve /stop/status/reload) started without--socketwhenXDG_RUNTIME_DIRis unset (there is no default socket path then).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.)
Logs (stderr)
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.
Backend
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 generic write/invoke value-encoding rule (scalars as literals, lists/structs as JSON from the generated type table). - First-fabric bootstrap is
mat fabric init(random-epoch IPK); see that section.
Environment variables:
| variable | purpose |
|---|---|
MAT_STORE | credential store path (see the resolution order in Configuration) |
MAT_IFACE | override interface autodetect for mat’s direct path |
MAT_MATD_IFACE | override interface autodetect for matd |
MAT_THREAD_IFACE | override Thread TUN interface autodetect for mat’s groupcast egress; unset = auto-select if exactly one multicast-capable wpan* iface is up, else LAN-only — see Groupcast egress |
MAT_MATD_THREAD_IFACE | same, for matd |
MAT_GROUP_EGRESS | all (default) / thread: with thread, skip the LAN copy of a groupcast while a Thread TUN egress is up (the border router would relay it into Thread a second time) — see Groupcast egress |
MAT_MATD_GROUP_EGRESS | same, 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_OP_TIMEOUT_MS | budget (ms) for a single-node op; default 60000, 0 = unlimited — see Op timeout budget |
MAT_PAA_TRUST_STORE | directory of PAA root certs for attestation |
MAT_CD_SIGNER_STORE | CD signer trust store (warn-only if absent) |
MAT_SETUP_CODE | commission setup code (QR MT:... or manual code) when --setup-code is absent; prefer it on shared hosts — the flag is visible in the process list (see Passing the setup code and Thread dataset safely) |
MAT_THREAD_DATASET | Thread active operational dataset (hex) for BLE+Thread commission; like MAT_SETUP_CODE, prefer it over --thread-dataset on shared hosts |
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.
Development
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 + doc:check + 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 needed
The Docker image runs as an unprivileged user (mat, uid 10001); the store
lives at /home/mat/.config/mat (what task docker:run mounts the mat-store
volume on). For a bind-mounted store, match the host directory’s owner or pass
docker run --user <uid>:<gid>.
CI (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.
Manual E2E (with real devices; not in CI)
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 --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).
State operations E2E
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": true
Share E2E (mat -> another admin)
Share 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).
Groupcast E2E (real devices)
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 off
Groupcast 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.