Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

--transportQR payload (MT:)manual code
auto (default)mDNS hit → on-network, then BLE if PASE times out; miss → BLEmDNS hit → on-network; miss → unreachable
on-networkmDNS only; never falls back to BLEsame
bleskips mDNS entirelyrejected (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; address is the live-resolved value (absent if the instance advertises no addresses — announce-only).
  • reachable: false — not advertising; no address (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 fixed temporary ipk 01 constant. 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 missing g/gdc makes mat refuse 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 .ini files by hand. Any other command run before fabric init returns store_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 run fabric init — the first native commission verifies 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_epoch says where the fabric’s IPK came from: "mat" for a fabric bootstrapped by fabric init (random epoch, recorded as mat/f/<idx>/ipk-epoch), "chip-tool" for a fabric created by chip-tool whose fixed epoch mat verified and adopted.
  • ipk_rotation_pending is true while a fabric rotate-ipk is pending (new epoch persisted, controller not yet switched — see IPK rotation below); rotate-ipk --abort clears it.
  • current marks the index this invocation would use for device ops (--fabric-index / MAT_FABRIC_INDEX, default 1).
  • Local only, like fabric init: it is dispatched before route selection, so --matd is ignored (no daemon is contacted). A missing store INI is store_missing (exit 10) with the fabric init hint. There is no fabric 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:

  1. 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.
  2. Every node (in --nodes order, one at a time) gets KeySetWrite on key set 0 carrying both epochs — the current one (epoch 0) and the new one (epoch 1). mat then re-establishes CASE with the new IPK to prove the node accepts it (KeySetRead never returns key material, so this is the only proof). A failure on one node does not stop the others.
  3. Only when every listed node succeeded does the controller switch: key set 0 in the store, mat/f/<idx>/ipk-epoch and -prev are 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.
  4. 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).

  • --nodes defaults to every node in the ledger; explicit ids / aliases must be commissioned (node_not_commissioned, exit 11). --op-timeout-ms budgets each node’s step (write + proof CASE) — exceeding it is timeout for that node, and the run continues.
  • --catch-up is refused while a rotation is pending (other): finish it by re-running mat fabric rotate-ipk (same nodes, or a --nodes subset), or --abort it first.
  • matd loads the IPK at start-up and keeps it in memory. A committed rotation (status: "rotated") asks a running matd to reload it over the socket and reports the outcome as matd_reload: reloaded (the daemon now holds the new IPK — no restart), not_running (no daemon answered the probed socket), or failed (an old matd without the reload op, or the reload itself failed — run matd reload, or restart it). The field is informational: it never changes the exit code. Until it reloads, matd keeps 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. commission picks the new epoch up immediately. The hint goes to MAT_MATD_SOCKET (or the default socket path) — with a daemon on a non-default socket (a container’s /run/matd/matd.sock, say) set MAT_MATD_SOCKET for the rotation, or run matd reload --socket … afterwards. A reloaded answer 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 list shows "ipk_rotation_pending": true while a rotation is pending; fabric rotate-ipk --abort clears it.
  • The virtual device matv follows the spec here too (§11.2.8.1): it accepts KeySetWrite on key set 0 with up to three epochs, persists them in group_keys.json, and answers CASE for every epoch it holds, so a rotation against matv commits and survives a device restart (scripts/e2e-device-m4.sh pins the success path, then the pending/abort path with the device stopped).
  • Never part of the matd socket protocol (direct-only like commission); explicit --matd exits 2.

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 (exit 3/4/5/6) and the ledger is left untouched — nothing ends up half removed. aliases_removed lists the node aliases that were deleted (its endpoint aliases go with them); it is [] when no aliases.toml exists.
  • A node that is not in the ledger is node_not_commissioned (exit 11), --force included: the ledger is what says a node can be unpaired at all.
  • --force only folds device-side failures — the transport / IM outcomes timeout / device_rejected / unreachable / session_failed (exit 3/4/5/6). Local failures (store_missing, store_parse, parse_error, interface-autodetect other) 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_id in nodes.json): commission without --node always hands out an id above every id ever issued, even after the highest one was unpaired. Ledgers written before this field existed fall back to max(ledger) + 1 on first load and then start tracking it.
  • With matd running, 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 of matd status. No restart needed. unpair is also the one direct-path op that does not send the node_touched hint — nudging matd to 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] } ] }

describe issues 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.
  • list and struct values are included as JSON, exactly as a single-attribute read prints 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 the MoreChunkedMessages train 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 attributes object, not an error.
  • Routed through matd like a normal read (see Routing through matd for 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_table follow chip-tool’s field-name convention (note Lqi in neighbors but LQIIn / LQIOut in routes), and routing_role is the numeric enum (5 = Router) — mat does not resolve names.

How to read it: a flaky node usually has few neighbor_table entries or a weak AverageRssi to its only neighbor (roughly: > -70 dBm healthy, < -85 dBm marginal). Only mains-powered, router-eligible devices relay (RxOnWhenIdle: true / not IsChild); adding battery sleepy end devices does not extend the mesh. Devices that share the same extended_pan_id are on the same Thread network (same border router); a partition_id that differs across nodes means the mesh has split.

Thread devices drop in and out, so diag returns partial results: each attribute is read independently, failures are listed under unavailable ([{ "attribute": ..., "kind": ... }]), and an unread field is null — distinct from [], which means a table was read and is genuinely empty (an isolated node). If every read fails (node fully unreachable) it exits with unreachable / timeout instead. Like describe, this reads several attributes but finishes in one shot. mat diag runs 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."
}

verdict is one of ok, ip_unreachable, link_starved, fabric_missing, not_advertised, unresolvable, session_failed, device_rejected, unknown. Without --deep the fast path can’t tell link_starved (weak Thread link, SRP not registered — the fabric is intact) apart from fabric_missing (the device dropped our fabric); --deep adds the mDNS and ping6 evidence that distinguishes them. --deep resolves 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, the ip check lands in unavailable as mdns_unresolved, and the split then rests on the Thread-side evidence. A failed mDNS probe itself (e.g. an interface I/O error) also lands ip as mdns_unresolved; the two cases are distinguishable because the probe-failure case additionally reports a {"check": "mdns", ...} entry under unavailable. Like diag thread it returns partial results (skipped/failed checks go under unavailable) and always exits 0 with a verdict, even when the node is fully unreachable — the value is in the classification, not an exit code. --deep shells out to ping6 (override with MAT_PING6_BIN) and does a native targeted mDNS resolve.

The operational and thread checks run natively over a single CASE session (one session serves both). diag node is direct path only — it is not part of the matd protocol.

mdns.advertised_self_fabric is whether the node advertises on our fabric specifically (vs. advertised_any_fabric, which is any fabric). It needs our compressed-fabric-id, which mat computes directly from the fabric’s KVS materials — so it is always available (the historical cfid_unavailable case, 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 } }
  ]
}

--nodes takes node_ids or aliases.toml node 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 — id is 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 a network object (all fields absent except an empty partition_ids) and a timestamp, without touching the backend.

A node’s stable id is ext:<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), and node:<node_id> for a fabric node whose ExtAddress could not be determined at all (e.g. probe failure). Unknown participants get a label from aliases.toml‘s [thread] section (see Aliases) instead of an alias, 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, shaped NextHop = 63 (invalid) plus PathCost = 0 with Age ≈ 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") or ExtAddress: 0 with 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 while partition_ids has more than one entry). mat never 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_by marks 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 a rloc16 — and still contributes its own viewpoint edges, anchored at node:<id> rather than an ext: vertex, from its own neighbor/route table rows. The same physical radio may also surface separately as an ext: 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, mat invalidates 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 encode mesh-local-prefix as 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); mat normalizes both to the 16-hex form before deriving RLOC16. A route-table row with ExtAddress: 0 that 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 mesh is direct path only (native, not part of the matd protocol) 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":false plus a probe_error ({"kind":...,"detail":...}) on that node, and the command still exits 0 with the partial graph — same philosophy as diag thread’s per-attribute unavailable. Only when every targeted node’s probe fails does diag mesh exit non-zero, mapped from the most common failure kind across nodes (e.g. all nodes unreachable exits 5; 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. --node accepts a node alias the same way other commands do; --cluster / --attribute are chip-tool notation (never aliased), same as read. If <store>/subscriptions.toml exists, only its listed clusters are ever subscribed by matd in the first place, so --cluster can 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); --attribute and --event are mutually exclusive.
  • --count (default 1) is how many lines to receive before exiting 0; 0 means no count limit — keep streaming (symmetric with --timeout-ms 0). Both kinds of line count: without --attribute / --event the stream carries attribute-change lines and device-originated event lines, so a default mat listen --count 1 returns whichever arrives first. Pass --attribute or --event when the count must be of one kind. --timeout-ms (default 60000) cuts the wait short; 0 means wait forever. Reaching --count exits 0; the timeout firing with zero events received exits 3 (with at least one event received, it still exits 0 — same UX as enl listen).
  • mat connects to matd, sends the listen request, 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: true marks 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. occupancy still 1 from before a restart) for a fresh trigger. Only scalar values become events — list / struct attributes (ACL, server-list, etc., which show up in a wildcard priming burst) are dropped with a debug log. This is a listen-only limitation (generic read and write handle lists and structs as JSON): the resident Subscribe processes each ReportData message on its own and does not reassemble chunked lists (MoreChunkedMessages plus per-element ListIndex: null appends), 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: true marks an event matd reconstructed from a priming report: the attribute’s value in the new subscription’s priming burst differs from the last value matd saw, so the transition happened while the subscription was down. Such an event is delivered with priming: false and recovered: true, so an existing consumer trigger fires on it without any change. Its timestamp is the receive time, not the time of the actual transition (which is unknowable — somewhere in the blind window). Values matd has never seen before (first priming after a matd restart) are not promoted: they are plain priming: true events, 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.toml or the mat listen --cluster / --attribute filters is the control.
  • A transition that matd’s own op caused during the blind window also comes back as recovered: 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 default mat listen with no filter) carry an event key instead of attribute — that is the field an existing consumer should switch on to tell the two kinds of line apart. A mat listen with no --attribute/--event now emits both kinds of line interleaved in receive order; an old mat client (pre-phase-B) and a new default mat listen both 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 no attribute key. Three examples — the first two are Generic Switch presses (spec shape), the third is a booleanstate state-change a real device sent during the phase B real-device smoke (node id and timestamp shown as example values):
    {"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}
    
    Keys: timestamp (receive time — same contract as attribute lines from the same ReportData), node_id, endpoint, cluster (name from mat-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 timestamp matd couldn’t resolve), priming. There is no recovered key on event lines — events are addressed by EventNumber, so a transition missed during a blind window is retrieved exactly (see the priming rule below), not inferred from a value diff the way attribute recovered is.
  • Bare --event matches every event name; --event <name> needs --cluster unless <name> is numeric — the same rule as --attribute. --attribute and --event together is parse_error (exit 2): a single listen stream is either attribute-filtered, event-filtered, or (with neither flag) unfiltered on both.
  • Event priming rule. matd keeps the last EventNumber it has seen per node in process memory only (lost on a matd restart — design rule 4). At matd start there is no EventFilter yet, so the very first Subscribe pulls the device’s whole event log and every one of those events arrives with priming: true (a consumer ignores them, same discipline as attribute priming). On a later re-subscribe within the same matd process (a dropped Thread link, eviction by another CASE session, …), matd sets EventMin = last + 1 in the new subscription’s EventFilters: events at or above that number are real transitions that happened during the blind window and arrive with priming: false — the event-side replacement for the attribute side’s recovered heuristic, exact by EventNumber instead of inferred from a value diff. A non-compliant device that replays events below EventMin anyway has those downgraded back to priming: true (the blind-window contract holds even if the device ignores the filter). Events within one ReportData fan out in ascending event_number order, with the same timestamp as any attribute lines from that same ReportData.
  • matd absent, refusing the connection, or dying mid-stream is matd_unavailable (exit 13) — see Errors and exit codes. Events already printed before a mid-stream matd loss stay printed; the process still exits 13 (not 3), even if --count was not reached.
  • --reconnect turns those three losses (connect failure, EOF, a non-JSON line) into a retry instead of exit 13, so a long-running consumer survives a matd restart — and can be started before matd is up. Backoff is 1s → 2s → 4s → … capped at 30s and reset on a successful connect; the attempts are logged to stderr (warn on loss, info on reconnect). No marker line is written to stdout — the contract stays one JSON event per line. --count accumulates across reconnects and --timeout-ms is a single overall deadline that also covers the waiting, so --count 5 --timeout-ms 60000 --reconnect means “5 events within a minute, restarts included” (deadline with zero events still exits 3). Note that the priming burst after each reconnect counts toward --count like any other event — key off the priming field if you only want real transitions. An error line matd sends inline (e.g. the slow-consumer lag disconnect) still terminates mat listen; --reconnect only covers losing the daemon.
  • Usage form (a consumer like casa reacts per line; mat/matd never run automations — see Backend / ARCHITECTURE.md “Design rules”):
    mat 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
    
    Prefer this resident stream over respawning one-shot --count 1 calls in a loop: matd fans events out over a live broadcast, so events arriving in the gap between one listen exiting 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) and qr_payload (the MT:... string).
  • Rendering the QR image is not mat’s job. stdout emits the qr_payload string only; drawing is out of scope.
  • --timeout defaults to 180 seconds. expires_at is the time mat built the response plus timeout.
  • If --discriminator is omitted, it is derived from the node_id deterministically (kept within 12 bits).
  • --iteration must be in 1000..=100000 (spec §3.9) and --discriminator within 12 bits (0..=4095). Out-of-range values fail fast with parse_error before 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-window shares native devices one at a time.
  • Watch the fabric count limit. A cheap node may support only ~5 fabrics, so several admins plus mat can use up the slots. When a hub acts as a bridge, mat does 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 invoke reports "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 with mat read if needed.
  • --epoch-key is optional. It is the 16-byte (32-hex) AES key shared by the group. Omit it and mat generates 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 an other error — 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, unrelated group provision.
  • --keyset-id defaults to 42, --name to grp<group_id>, --endpoint to 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. Use mat fabric rotate-ipk instead.
  • 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 detail says 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: mat reads 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, mat stops with parse_error and 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 KVS mat writes directly), so re-running provision on an existing group fails with a duplicate-bind error (use --rebind in the detail) — 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 --rebind this 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 with mat read -e 0 -c groupkeymanagement -a group-key-map. A direct-path --rebind needs no follow-up on matd: it re-reads the group’s operational credentials from the KVS on every send (see “Pick one group sender” below).
  • mat group list shows 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 --matd is ignored (it is dispatched before route selection, like fabric init). Epoch keys, operational keys and GKHs are deliberately not in the output — they are credentials. groups[].keyset_id is null for a group with no key-map row, and keysets[].bound_groups is the reverse mapping. A fabric that was never provisioned prints empty arrays and exits 0; a broken chain is store_parse.
  • mat group remove is the reverse of provision, direct path only (like grant: a rare repair/teardown op whose controller-side state only mat owns, so --matd exits 2). Per node it strips the ACL Group entry, sends RemoveGroup, drops the group’s group-key-map row 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.
    • --nodes is required: the controller-side state holds no membership list, so mat cannot 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_FOUND and is reported as group_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_removed comes back false and the exit code stays 0. 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 list afterwards is the check that the controller side is clean.
  • mat group grant repairs older groups. Groups provisioned before this step existed — including any provision routed through a matd ≤ 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 — use provision --rebind to re-run it on an existing group; grant runs just the ACL step instead. It is direct path only (--matd exits 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) forces mat’s own direct path, no probing. Otherwise (default) mat auto-detects: it probes the socket with a connect and falls back to the direct path when nobody answers. MAT_MATD_SOCKET just 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 systemd RuntimeDirectory=matd convention, 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.sock fallback; it is gone because in a world-writable /tmp another local user could plant a fake socket there while matd is down and mat would send it ops and trust its answers. Now there are zero default candidates: auto-detect behaves exactly as “no matd running” (direct path), forced --matd and mat listen are matd_unavailable (exit 13, immediately — listen --reconnect does not wait for a socket that cannot appear), and matd itself (serve / stop / status / reload) refuses with other (exit 1) asking for --socket or XDG_RUNTIME_DIR. Give an explicit socket with --matd <path> / MAT_MATD_SOCKET on the mat side and --socket on the matd side.
  • Access control is the socket’s file mode. Anyone who can connect can run every op (write / invoke / shutdown / reload included). matd sets the socket to 0600 right after bind (independent of umask) and creates <socket>.lock as 0600; the default socket directory is created 0700. Keep an explicit --socket in 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_error line back and the connection is closed — matd never buffers past the cap, and other connections are unaffected.
  • Once connected, errors are reported from the matd path as-is — mat never 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 grant and group remove are direct only — see Groupcast above). discover / commission / unpair / fabric init / open-window / diag / fabric rotate-ipk are direct-only: auto-detection skips them silently; explicit --matd exits 2. fabric list and group list are neither: they are pure local KVS reads dispatched before route selection, so no daemon is contacted and --matd is simply ignored (not exit 2). 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 reachable matd it is matd_unavailable (exit 13).
  • Version skew: a new mat against an older matd (≤ 1.30.0). Two of the additions extend the socket protocol, so an older daemon does not understand them. A cluster-wildcard read (--attribute omitted) is rejected by the old matd with parse_error (exit 1) for the missing attribute field, and --timed on write / invoke is silently dropped (an unknown field is ignored, so the op runs untimed). Upgrading matd fixes both; until then run those calls on the direct path:
    MAT_MATD=0 mat read --node 5 --cluster onoff
    MAT_MATD=0 mat invoke --node 5 --cluster onoff --timed --command off
    
    The reverse direction is safe: an older mat against a new matd sends neither field and behaves exactly as before.
  • Version skew: mat listen --event against an older matd (≤ 1.35.0). The event filter is a new key in the listen request, and an old daemon ignores unknown keys — so --event is silently dropped: attribute lines stream as before and --count 1 returns an attribute line instead of an event line (no error, exit 0). There is no direct-path fallback for listen, so the fix is to upgrade matd before mat — an older mat against a new matd keeps working unchanged (it never sends the key). Older daemons also never subscribe to EventRequests at all, so no event line exists to receive in the first place.
  • node_id commissioning is re-checked by matd against 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 at info — 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::broadcast channel to every connected mat listen client, 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.
  • matd holds no event history (no ring buffer, no replay): a mat listen client only sees events emitted while it is connected, same as enl listen. priming (see Listen) is the mechanism for telling initial-state reports apart from later changes without needing a replay log.
  • listen is the only op that breaks the “one line request = one line response” rule of the matd socket 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 EventRequests are always sent IsUrgent = true — non-urgent event paths would only report at the next keep-alive (≤300s), which defeats a button’s purpose, so matd never offers that knob. Attribute and event paths narrow independently via subscriptions.toml’s clusters / events keys — see Subscriptions (subscriptions.toml, optional, matd only). On the wire, Op::Listen gained 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 unfiltered listen — an old mat client never sends it and only ever receives attribute lines, as before. Emitted lines fan out through the same tokio::sync::broadcast channel as attribute events, carrying either variant of matd’s internal Emitted enum (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 the EventMin priming rule. Not yet implemented (tracked as future work): a DataVersionFilter and 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 for mat.
  • MAT_MATD_IFACE (or matd --iface <name>) overrides it for matd. These are deliberately separate names for two different processes; matd refuses 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). matd enforces it and returns a structured timeout (exit 3) once the budget is spent. mat’s own socket read is given a backstop timeout of budget + 2s, so an old matd (which ignores deadline_ms) that is alive but stuck — hung, or just slow to answer — still surfaces as timeout instead of hanging forever; the detail notes the request may already have executed. A matd that dies mid-request closes the socket instead, which mat sees as EOF and reports as matd_unavailable (exit 13), same as any other mid-stream matd loss.
  • Direct path: the same budget wraps the whole op in a tokio::time::timeout; exceeding it is timeout (exit 3) 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 in timeout when the budget is spent. On total failure the session_failed detail lists every address tried.
  • A request that omits deadline_ms (an old mat talking to a new matd) gets matd’s own default budget of 60s applied — the same number --op-timeout-ms defaults 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-ms entirely — unchanged, no read timeout either.

Fabric index, sessions, epoch

  • MAT_FABRIC_INDEX (default 1) and MAT_ISSUER_INDEX (default 0) select the KVS fabric-table and CA-issuer entries for mat; matd mirrors them as MAT_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 not 1.
  • 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, matd skips 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). commission needs the fabric’s epoch IPK (the key AddNOC hands the device — distinct from the KDF-derived operational key that is the only one persisted). It is resolved in order: (1) the mat-owned KVS key mat/f/<idx>/ipk-epoch if 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 a store_parse hard error. A fabric created by mat fabric init starts 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 or 0x.. (uint / enum / bitmap), decimal (int), 1.5 / 2e-3 (float — single / double pick TLV f32 / f64), any text (string), hex:aabb (octstr). null writes 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 shape read prints — so a read result 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 implicit fabric-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 are parse_error with the offending path in detail.
  • 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 a parse_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.

  • mat and matd share 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 matd is running, send all groupcasts through it. Its warm engine re-reads the group’s operational credentials from the KVS on every send, so a group provision --rebind takes 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 (a tracing::warn! is logged, but routing is unchanged — refusing the send is a product decision, not made here). With matd running, route priority already sends every group op to it first, so you normally never reach for MAT_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 &