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

mat

mat is a command-line Matter controller built for scripts and AI agents: a from-scratch, pure-Rust Matter stack that runs in-process and prints exactly one JSON object per command on stdout.

This site is the command and configuration reference. For the project overview, Quickstart, and design background, see the GitHub repository.

  • Commands — every command with its JSON output
  • Configuration — the credential store, aliases.toml, subscriptions.toml
  • Errors and exit codes — error schema, kind table, exit codes, stderr logs
  • Backend — the native backend, interface auto-detection, environment variables
  • Development — build / test tasks, manual E2E

Commands

Discover and commissioning

# Discover commissionable / commissioned nodes (ledger only, fast)
mat discover

# Also probe live reachability of commissioned nodes via mDNS
mat discover --probe

# Join a fabric (first commission OR multi-admin join, both supported)
# All values here are dummy (RFC 5737 192.0.2.0/24)
mat commission --setup-code "MT:Y.K9042C00KA0648G00" --node 5

Passing the setup code and Thread dataset safely

The setup code (QR payload or manual code) contains the device’s setup passcode, and --thread-dataset carries the Thread network credentials (network key included). Command-line arguments are visible to every local user in the process list (ps, /proc/<pid>/cmdline) while mat runs, so on a shared host pass both through the environment instead:

# MAT_SETUP_CODE / MAT_THREAD_DATASET are read when the flag is absent;
# an explicit --setup-code / --thread-dataset wins over the env var.
MAT_SETUP_CODE="MT:Y.K9042C00KA0648G00" mat commission --node 5

The setup code stays required — from the flag or MAT_SETUP_CODE; with neither, commission is a usage error (exit 2). --help names both env vars but never prints their values.

Route selection (--transport)

commission picks how to reach the device. mDNS finding a record only proves the record exists — a Thread device’s SRP registration outlives the device’s reachability — so auto keeps BLE as a fallback:

--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 &

Configuration

Credential store

Resolution order: --store <path> > $MAT_STORE > $XDG_CONFIG_HOME/mat > ~/.config/mat. It holds the Root CA, the controller’s keys/cert, the commissioned-node ledger (nodes.json), the optional alias map (aliases.toml, below), and the persistent Matter KVS (chip-tool-compatible INI form — group keysets, operational credentials, the group-send counter; mat is its sole reader/writer). It is never committed (excluded by .gitignore).

The store holds the Root CA private key and the IPK in plaintext, so mat writes every store file owner-only (0600, regardless of umask — each write is a tmp+rename replace, so the mode is re-applied every time) and creates a missing store directory as 0700. An existing directory’s mode is left as is (e.g. a mounted Docker volume) — make sure it is not world-readable.

matd socket

matd binds $XDG_RUNTIME_DIR/matd/matd.sock by default (directory created 0700) and mat probes that path (then the pre-0.27.0 $XDG_RUNTIME_DIR/matd.sock). Without a usable XDG_RUNTIME_DIR (unset, empty, or relative) there is no default — the old /tmp/matd.sock fallback was removed because another local user could pre-create a fake socket in the shared /tmp. Then matd needs --socket <path>, and mat finds no daemon unless given --matd <path> / MAT_MATD_SOCKET (auto-detect simply uses the direct path). The socket is set to 0600 after bind and <socket>.lock is created 0600: the file mode is the only access control, so put an explicit socket in a directory only that user can write to. See Routing through matd.

Aliases (aliases.toml, optional)

Numeric node / group / endpoint ids are easy to get wrong. If the file <store>/aliases.toml exists, mat resolves locally defined names to those numbers right after arg parsing — a purely local convenience. Without the file, behavior is exactly the traditional numeric-only one. The wire and the backend (native engine / matd) always receive numbers, and stdout keeps the numeric schema (no alias echo-back).

version = 1

[nodes]
living-light = 5
hall-sensor = 12

[groups]
all-lights = 258

[endpoints.living-light]
main = 1
night = 2

[endpoints.12]
pir = 3

[colors]
warm = "#ff8c00"
mypink = "255,182,193"

[thread]
"AABBCCDDEEFF0011" = "otbr-br"
  • nodes: alias → node_id. Accepted by -n/--node (read / write / invoke / describe / on / off / color-temp / color / level / open-window / diag thread / diag node) and by --nodes in group provision / diag mesh (each element resolved independently).
  • groups: alias → GroupId. Accepted by -g/--group in group provision / invoke / grant / color-temp / color / level (bump takes none — the counter is fabric-global).
  • endpoints: defined per node — the outer key is a node alias or a node_id digit string, the inner map is alias → endpoint number (endpoint numbers mean different things on different nodes, so there is no global endpoint dictionary). Accepted by -e/--endpoint on node-taking commands; the lookup uses the resolved node, so -n 5 -e main and -n living-light -e main give the same result. The -e of group provision / group invoke / group color-temp / group color / group level is numeric only (no node context to resolve against).
  • colors: custom color name → RGB value (#rrggbb / rrggbb / R,G,B), used by --name in color / group color. Entries are defined as RGB and go through the same RGB → HSV pipeline as --rgb. A user-defined name overrides the built-in color table (you can redefine red). Without the file the built-in table still works. A value that does not parse as RGB is store_parse (exit 10); an unknown color name is a CLI argument error (exit 2) listing the known names.
  • thread: Thread ExtAddress (16 hex, case-insensitive) → display label, used by mat diag mesh to name unknown participants (OTBR border routers, other- fabric devices) that show up in a neighbor/route table but were never commissioned onto this fabric, so they have no nodes alias to fall back on. The graph’s label field matches on ExtAddress regardless of fabric status, so a commissioned node whose ExtAddress happens to be listed here gets a label too, alongside its nodes alias.
# With the aliases.toml above, these are equivalent:
mat on -n living-light
mat on -n 5

Resolution rules:

  • A value that parses as a number is used as-is (numbers win; full backward compatibility). Only non-numeric values are looked up in aliases.toml.
  • Alias names must be non-empty and not all digits (this shadowing is rejected when the file is loaded: store_parse, exit 10).
  • An unknown alias — or any alias given when there is no aliases.toml in the store — is a CLI argument error (exit 2); the stderr detail lists the known aliases (or says no aliases.toml in store) so the caller can self-correct.
  • A corrupt aliases.toml is store_parse (exit 10).
  • Cluster / attribute / command names are never aliased (chip-tool notation only).

These map onto the existing exit codes (2 / 10); the Errors and exit codes table is unchanged.

To register an alias while commissioning, add --alias:

mat commission --setup-code "MT:Y.K9042C00KA0648G00" --node 5 --alias living-light

The name is validated before commissioning starts (all-digits / empty / already taken → exit 2, before any network op runs), and it is written to aliases.toml only on success (the file is created if absent). Without --alias, commission never touches aliases.toml. Deleting or renaming an alias is a hand edit of the file — there is no management subcommand.

Subscriptions (subscriptions.toml, optional, matd only)

By default matd‘s resident Subscribe (see Resident Subscribe and mat listen) is a full wildcard: every endpoint/cluster/attribute, on every commissioned node. If <store>/subscriptions.toml exists, matd narrows that to just the listed clusters’ paths instead.

Full-wildcard priming (the initial full-attribute dump right after a subscription is (re)established — dozens of request/response round trips) can fail to complete on a weak Thread link, leaving a subscription unestablished for tens of minutes to hours. Narrowing to a handful of clusters shrinks priming to one or two chunks, so a link good enough for read is usually good enough to subscribe on too.

clusters = [
  "onoff",
  "occupancysensing",
  "temperaturemeasurement",
]
  • Cluster names use chip-tool notation (same as mat read); numeric ids ("0x0006" / "6") also work, the same escape hatch as elsewhere for names mat-core::ids doesn’t know.
  • Absent file = full wildcard, unchanged — the same absent-file discipline as aliases.toml.
  • A parse failure, an unknown cluster name, or an empty list makes matd refuse to start (store_parse, exit 10); it never silently falls back to wildcard, so a misconfiguration can’t quietly disable the weak-link workaround.
  • Edge case: nodes that serve none of the listed clusters. When the file is present, the narrowed Subscribe is sent to every commissioned node; a node that exposes none of the listed clusters will never establish its subscription (it retries on backoff forever). Ensure each node serves at least one of the listed clusters.
  • When this file is present, mat listen’s attribute lines only ever come from the listed clusters — a --cluster filter naming a cluster outside that set simply never matches anything on the attribute side. (This narrows attribute paths only; the events key below narrows event paths independently — see there for what “no event lines outside the list” means on the event side.)
  • Read once at matd startup; an edit needs a matd restart to take effect (e.g. systemctl --user restart matd).
  • mat (one-shot) never reads this file — like the rest of the resident Subscribe, it is matd-only.

events — event subscription scope

events is independent of clusters above: clusters narrows the attribute paths of the resident Subscribe, events controls which clusters’ events (EventRequests) it asks for. Either key can be present without the other, and a file with only one of them is valid.

# All three forms are independent of `clusters`.
events = ["switch", "booleanstate"]   # only these clusters' events
events = []                           # no EventRequests at all
# events key omitted entirely          -> all clusters' events (wildcard)
  • Key absent — every cluster’s events, urgent priority (the same wildcard discipline as an absent subscriptions.toml).
  • events = [...] — only the listed clusters’ events, urgent priority. Same name/numeric resolution as clusters (chip-tool notation or "0x0006"/"6", unknown names are store_parse).
  • events = [] — no EventRequests at all: the resident Subscribe goes out exactly as it did before event subscription support (Phase A), i.e. attribute reports only.
  • All event paths are sent IsUrgent = true regardless of which of the three forms is in effect — there is no non-urgent option (see Resident Subscribe and mat listen for why).
  • Rollout / canary. Order matters: update the mat listen consumers first, then enable events. Even the narrow canary list puts booleanstate state-change lines (and switch presses) into every unfiltered mat listen stream, and those lines count against --count, so a consumer doing mat listen --count 1 can get an event line where it used to get an attribute line. Give each consumer either a branch on the attribute / event key or an explicit --attribute flag before turning the key on. Then start production matd with events = ["switch", "booleanstate"] rather than jumping straight to wildcard: watch priming time across all nodes and stderr for any device rejecting the EventRequests path — matd logs one warn per report with rejected (count) plus the first rejected endpoint/cluster/event/status, and INVALID_ACTION during establish/priming — then widen by removing the events key entirely once that looks clean. events = [] is the instant off switch if the widen step goes wrong.
  • Upgrade caveat — absent means wildcard. Upgrading matd to a phase-B-or-later build with an existing subscriptions.toml that has no events key turns on wildcard event subscriptions for every commissioned node the moment matd restarts (the same absent-file default as clusters, but new behavior the day this key first exists). If that is not what you want on day one, add an explicit events = [...] (or events = []) line to subscriptions.toml before deploying the upgrade, following the canary path above.

Errors and exit codes

Errors go to stderr as {"error":{"kind":"...","detail":"..."}}.

codemeaning
0success
2CLI argument error (clap default)
10credential store missing / parse failure
11node_id not commissioned
12(retired in 0.22.0 — historical vacancy)
3timeout (includes a single-node op exceeding its --op-timeout-ms budget)
4device rejected
5unreachable / network
6CASE session establishment failed
13matd absent / unreachable (mat listen only; mat listen --reconnect retries with backoff instead of exiting — see below)
1other

When commission tries more than one route, the reported kind and exit code are those of the last route attempted, and detail lists every route’s result.

The native backend maps its own transport/IM outcomes onto 3 / 4 / 5 / 6; anything it cannot classify is exit 1. An operational mDNS resolve timeout (the node did not advertise within the wait window — often recoverable by retrying, since Thread border routers advertise on a ~30s cycle) is timeout (exit 3); any other resolve failure (socket I/O, etc.) is unreachable (exit 5).

mat fabric rotate-ipk is the one command that can both print a body on stdout and exit non-zero: a partial failure (status: "pending" / "catch_up_incomplete") emits the per-node result as JSON on stdout, then reports the first failed node’s kind on stderr (and exits with its code) so scripts that only check the exit status still notice. stdout stays pure JSON.

kind values (stable; callers may branch on these strings):

  • store_missing / store_parse — credential store missing / corrupt (exit 10). store_missing typically means you have not run mat fabric init yet.
  • node_not_commissioned — node_id not in the store (exit 11)
  • timeout (exit 3) / device_rejected (exit 4) / unreachable (exit 5) — classified from the native transport / IM result. timeout is also what a single-node op returns once its budget runs out (--op-timeout-ms on mat, or matd’s own 60s default when the request carried none) — see Op timeout budget.
  • session_failed — IP reachable but CASE (operational secure session) could not be established, e.g. an intermittent CHIP Error 0x54 (Invalid CASE parameter) during the Sigma exchange (exit 6). Distinct from unreachable (no IP route) and device_rejected (the device answered and refused); typically retryable.
  • commission_failed — commissioning failed (unclassified residue, exit 1). Since 1.0.0 timeouts during PASE/CASE map to timeout and explicit device refusals (wrong passcode / StatusReport rejection / bad Sigma2 signature) map to device_rejected instead of landing here.
  • parse_error — this kind is returned when a generic write / invoke value does not fit the type the generated table knows for the attribute / command field (bad literal, JSON of the wrong shape, unknown / missing struct field — the detail names the path), when a list / struct JSON is given with a numeric id (no schema), or when a cluster / attribute / command name is not in the table (pass the numeric id instead). matd also answers parse_error to a request line longer than 1 MiB, then closes that connection.
  • matd_unavailable (exit 13) — matd was not reachable or died mid-request. For mat listen: no socket (including no socket candidate at all — XDG_RUNTIME_DIR unset and no --matd <path> / MAT_MATD_SOCKET; exits immediately even with --reconnect), connection refused, MAT_MATD=0, or the connection was cut partway through the event stream (mat listen has no direct-path fallback). With mat listen --reconnect those losses (connect failure, EOF, a non-JSON line) are retried with backoff (1 s → 30 s) instead of exiting 13; MAT_MATD=0 still exits 13 immediately (the route is resolved before the stream, so there is nothing to reconnect to), and an error line matd sends inline still terminates the run. Since 1.0.0 also for every other op on the matd path: forced --matd failing to connect, or an I/O failure / silent disconnect after the request line was sent (the request may or may not have been executed — the detail says so; there is deliberately no direct-path retry, to avoid double execution of writes). Distinct from timeout (exit 3), which mat listen uses only for “connected fine, zero events arrived before --timeout-ms.”
  • other — anything else (exit 1); also what a group provision KVS write returns once the write is attempted and fails — including a duplicate bind (detail says use --rebind) or the KVS being locked by a concurrent writer (flock WouldBlock). These are hard errors (the KVS may already be touched), distinct from an unresolvable KVS, which surfaces as store_missing / store_parse. Ambiguous interface autodetect is also other, as is matd (serve / stop / status / reload) started without --socket when XDG_RUNTIME_DIR is unset (there is no default socket path then).
  • child_not_found (exit 12) / child_failed (exit 1) — not emitted as top-level errors since 0.22.0 (they classified chip-tool spawn/exit failures, now removed). The variants and exit-code mapping are kept only for wire compatibility with responses from older mat / matd. (mat still constructs child_not_found internally to record a missing ping6 as a tool_missing entry inside diag node --deep’s unavailable array — this never becomes exit 12.)

Logs (stderr)

mat and matd write diagnostics to stderr as structured tracing logs (stdout stays pure JSON). The filter comes from MAT_LOG, falling back to RUST_LOG; the default is warn for mat and info for matd. An empty value counts as unset, so MAT_LOG= falls back to the default instead of silencing everything.

matd never emits ANSI escapes, and mat colors only when stderr is a terminal — so grep node_id=42 works on a journal or through a pipe.

matd logs one line per op:

linelevelwhen
matd op failedwarnthe path itself failed — timeout / unreachable / session_failed / other / commission_failed / matd_unavailable (plus the retired child_* kinds). Carries kind and detail.
matd op rejectedinfothe request or its meaning was refused — store_missing / store_parse / node_not_commissioned / device_rejected / parse_error
matd op slowinfosuccess that took ≥ 300 ms. A warm session is normally 71–149 ms, so this is the early sign of a weak link or a degraded mesh.
matd op okdebugordinary success — not shown at the default level

Fields: op, node_id / group_id, endpoint, path (cluster/attribute or cluster/command), elapsed_ms (the op itself, excluding JSON handling). Absent fields are omitted rather than printed as None. String values are quoted by the formatter, numbers are not:

WARN matd::server: matd op failed op="read" node_id=42 endpoint=1 path="occupancysensing/occupancy" elapsed_ms=8134 kind=Timeout detail=no acknowledgement within MRP retry budget

Note that kind prints the Rust variant name, not the JSON spelling — the log says kind=Timeout where the error object says "kind": "timeout".

Related lines:

  • no warm session; establishing (info) — a CASE session had to be built for this op. Repeatedly for one node means session churn.
  • listen client attached / listen client detached (info) — an event-stream client connected or went away. detached carries delivered and reason (client_disconnected / channel_closed).
  • subscription established / report pump ended / subscription lost; resubscribing (info) — the resident Subscribe lifecycle (see Subscriptions).

journalctl -p warning gives you just the degradation.

Backend

mat’s backend is a native, from-scratch Rust Matter controller (crate mat-controller, driven through the shared mat-native engine) — TLV, CASE, IM, groupcast, mDNS, and commissioning (on-network + BLE+Thread) are all in-process. There is no chip-tool (or any external controller) subprocess.

  • Route selection is per-op: matd auto-discovery (if a matd answers the probed socket) → mat’s own native direct path. See Routing through matd and Native backend internals for interface autodetect (MAT_IFACE / MAT_MATD_IFACE override), fabric index, warm vs one-shot sessions, the shared groupcast counter, epoch adoption, and the generic write/invoke value-encoding rule (scalars as literals, lists/structs as JSON from the generated type table).
  • First-fabric bootstrap is mat fabric init (random-epoch IPK); see that section.

Environment variables:

variablepurpose
MAT_STOREcredential store path (see the resolution order in Configuration)
MAT_IFACEoverride interface autodetect for mat’s direct path
MAT_MATD_IFACEoverride interface autodetect for matd
MAT_THREAD_IFACEoverride Thread TUN interface autodetect for mat’s groupcast egress; unset = auto-select if exactly one multicast-capable wpan* iface is up, else LAN-only — see Groupcast egress
MAT_MATD_THREAD_IFACEsame, for matd
MAT_GROUP_EGRESSall (default) / thread: with thread, skip the LAN copy of a groupcast while a Thread TUN egress is up (the border router would relay it into Thread a second time) — see Groupcast egress
MAT_MATD_GROUP_EGRESSsame, for matd
MAT_FABRIC_INDEX / MAT_ISSUER_INDEXmat KVS fabric-table / CA-issuer index (default 1 / 0)
MAT_MATD_FABRIC_INDEX / MAT_MATD_ISSUER_INDEXsame for matd
MAT_MATD / MAT_MATD_SOCKETforce / opt out of the matd path; pin its socket
MAT_OP_TIMEOUT_MSbudget (ms) for a single-node op; default 60000, 0 = unlimited — see Op timeout budget
MAT_PAA_TRUST_STOREdirectory of PAA root certs for attestation
MAT_CD_SIGNER_STORECD signer trust store (warn-only if absent)
MAT_SETUP_CODEcommission setup code (QR MT:... or manual code) when --setup-code is absent; prefer it on shared hosts — the flag is visible in the process list (see Passing the setup code and Thread dataset safely)
MAT_THREAD_DATASETThread active operational dataset (hex) for BLE+Thread commission; like MAT_SETUP_CODE, prefer it over --thread-dataset on shared hosts
MAT_TRANSPORTcommission route: auto (default) / on-network / ble
MAT_PING6_BINoverride the ping6 binary used by diag node --deep
MAT_LOGtracing filter for stderr logs (e.g. info); empty counts as unset — see Logs

Matter uses mDNS / IPv6 multicast, so running in Docker requires host networking (docker run --network host). A bridge network cannot receive the responses.

Development

Tasks are defined with Task (task lists them).

task build            # release build -> target/release/{mat,matd}
task install          # install both binaries into ~/.cargo/bin
task run -- discover  # run (native backend)
task test             # tests (native FakeConn + binary integration; no real devices)
task clippy           # lint (-D warnings)
task fmt              # format
task check            # CI equivalent (fmt:check + clippy + doc:check + test)

task dist:arm64       # aarch64-gnu + BLE deploy build -> dist/arm64/{mat,matd}
task docker:build     # slim x86_64 image (mat/matd only)
task docker:run -- discover
task docker:test      # no local toolchain needed

The Docker image runs as an unprivileged user (mat, uid 10001); the store lives at /home/mat/.config/mat (what task docker:run mounts the mat-store volume on). For a bind-mounted store, match the host directory’s owner or pass docker run --user <uid>:<gid>.

CI (GitHub Actions, .github/workflows/ci.yml) runs the same fmt / clippy / test sequence as task check. The default build and CI do not use the ble cargo feature; deploy builds (task dist:arm64) enable it for BLE+Thread commissioning.

Manual E2E (with real devices; not in CI)

In practice the main path is multi-admin join: adding a device that is already commissioned by another admin (such as Home Assistant) to mat as well. The printed code does not work (the device left commissioning mode), so the existing admin opens a commissioning window to issue a one-time code.

  1. Share from the other admin: on the other controller, run “Share” for the target device and note the issued setup code (MT:... or 11-digit).
  2. Join with mat:
    mat commission --setup-code "<issued setup code>" --node 5
    
    It returns { "node_id": 5, "status": "success" } and records the ledger in ~/.config/mat/nodes.json.
  3. Confirm: mat discover now shows node 5 with "state": "commissioned".

For a factory-reset device, pass the printed setup code directly to commission (first commission).

State operations E2E

Against a commissioned node (node 5 above), confirm read / describe / on / off on a real device.

# Introspect what you can call (endpoints and numeric cluster IDs)
mat describe --node 5

# Read the OnOff attribute (for a light, its current on/off state)
mat read --node 5 --cluster onoff --attribute on-off

# Turn on -> off (invoke of the OnOff command, not an attribute write)
mat on --node 5
mat off --node 5

# Read-after-write check (confirm the value took effect)
mat on --node 5 && mat read --node 5 --cluster onoff --attribute on-off   # -> "value": true

Share E2E (mat -> another admin)

Share mat-owned node 5 with another controller.

# Open a commissioning window (get the issued code)
mat open-window --node 5 --timeout 300
# -> { "node_id": 5, "manual_code": "...", "qr_payload": "MT:...", "expires_at": "..." }

Enter the returned manual_code (11-digit) or qr_payload (render the QR with the receiving tool) into the other controller’s “Add device” flow (Alexa / Apple Home / Google Home). Finish before expires_at. After sharing, mat keeps its fabric membership (multi-admin).

Each one-shot run pays mDNS resolution plus a CASE handshake, so a single call is slow (hundreds of ms to seconds). Speed-sensitive use cases run matd, which keeps warm sessions (see ARCHITECTURE.md).

Groupcast E2E (real devices)

With several commissioned lights (say nodes 5, 6, 7), burn a wire group and fire one multicast send at it.

# Provision the group onto every node (controller-side state is set up too)
mat group provision --group 1 --nodes 5 6 7 --name living
# -> { "group_id": 1, "keyset_id": 42, "nodes": [5,6,7], "status": "provisioned", ... }

# One multicast send — all three should react together (no popcorn effect)
mat group invoke --group 1 --cluster onoff --command on
mat group invoke --group 1 --cluster onoff --command off

Groupcast is unacknowledged, so group invoke only confirms the send, not delivery. If a light did not react, confirm it individually (mat read --node 6 -c onoff --attribute on-off) and re-provision that node. Multicast is especially weak on Thread; Wi-Fi / Ethernet lights are more reliable. The KVS records mat writes (keyset table, group table, GroupKeyMap) follow the connectedhomeip v1.4.2.0 GroupDataProviderImpl link discipline, so a real chip-tool on the same store can still read them — if a devices-side provisioning step regresses, the group-settings writer is the first place to check.

If no device reacts although provision reported success, suspect the device ACL first: provisions made before the ACL step (or through an old matd ≤ 0.12) never granted the group permission, and devices silently drop unauthorized groupcast. mat group grant --group 1 --nodes 5 6 7 adds the missing entries idempotently.