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 &