Getting started: the device side¶
This page builds a Matter bridge with one on/off light, pairs it, and walks
through what each part of the program does. The program is
examples/quickstart-device/main.go; its test builds it, starts the node on
loopback, stops it and starts it again on the same database.
node/ or bridge/?¶
Embed node/ unless you have a reason not to. node.New performs the
whole bring-up the reference daemon used to do by hand: the stores, the root
and Aggregator endpoints, PASE and CASE, the subscription engine, the
commissioning window, the fabric hooks, and every port of bridge/ in its
documented order. You configure it with one node.Config and add devices to
its Aggregator.
Use the facade with your own topology when you already have an endpoint
assembler over your own device model: set Config.Topology to your
bridge.Snapshotter. The facade then builds no Aggregator, and everything
else (stores, root servers, sessions, group state) stays the facade's.
Use bridge/ directly only if you need to replace one of the
collaborators the facade wires. bridge.New installs a no-op for every port
you do not attach, so a half-wired bridge binds its socket, publishes mDNS
and answers datagrams wrongly. The ports guide lists
every port, what skipping it costs, and whether the failure is visible.
Node.Bridge() gives you the bridge for a port the facade does not cover
without leaving the facade.
Source: node/doc.go:4-43, node/config.go:94-103, bridge/bridge.go:29-47, node/node.go:601-603.
The program¶
// SPDX-License-Identifier: Apache-2.0 // Copyright (C) 2026 SukramJ.
// Command quickstart-device is the smallest Matter bridge on go-fabric: one // on/off light under the node's Aggregator, its state in one SQLite file, // advertised over DNS-SD. docs/getting-started/device.md walks through it. // It pairs with the CSA test identity and is not a product. package main
import ( "context" "database/sql" "fmt" "log/slog" "os" "os/signal" "slices" "syscall"
_ "modernc.org/sqlite" // the host picks the SQLite driver
"github.com/SukramJ/go-fabric/cluster/onoff"
"github.com/SukramJ/go-fabric/device"
"github.com/SukramJ/go-fabric/endpoint"
"github.com/SukramJ/go-fabric/mdns"
"github.com/SukramJ/go-fabric/node"
)
func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) err := run(ctx) stop() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } }
func run(ctx context.Context) error { db, err := openDB("quickstart-device.db") if err != nil { return err } defer func() { _ = db.Close() }()
// DNS-SD: the subtype responder answers the discriminator-filtered
// browse every commissioner sends for a QR or manual code.
zc := mdns.NewZeroconf()
if r, err := mdns.NewSubtypeResponder(slog.Default()); err == nil {
r.Start(ctx)
zc.AttachSubtypeResponder(r)
defer func() { _ = r.Close() }()
}
n, err := newNode(ctx, db, node.Network{}, zc)
if err != nil {
return err
}
qr, manual, err := n.PairingCodes()
if err != nil {
_ = n.Stop(context.WithoutCancel(ctx))
return err
}
fmt.Printf("QR code: %s\nManual code: %s\n", qr, manual)
return n.Run(ctx) // Start, wait for ctx, Stop
}
// openDB opens the SQLite file; foreign_keys carries the store's // cascade-delete on fabric removal. node.New applies the schemas. func openDB(path string) (*sql.DB, error) { return sql.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)") }
// newNode builds the node and adds the light, unstarted. func newNode(ctx context.Context, db *sql.DB, network node.Network, adv mdns.Advertiser) (*node.Node, error) { n, err := node.New(node.Config{ //nolint:contextcheck // the node's constructor takes no context Network: network, Commissioning: node.Commissioning{ // change all four for anything real Passcode: 20202021, Discriminator: 3840, PBKDFIterations: 1000, Salt: []byte("go-fabric-quickstart"), }, ProductDescription: node.ProductDescription{VendorID: 0xFFF1, ProductID: 0x8001}, // CSA test vendor BasicInformation: node.BasicInformation{ VendorName: "quickstart", ProductName: "quickstart-device", NodeLabel: "Quickstart bridge", SerialNumber: "QUICKSTART-0001", }, Store: node.Store{DB: db}, Advertiser: adv, }) if err != nil { return nil, err } light, err := device.NewOnOffLight(device.Options{StableKey: endpoint.StringKey("light-1"), Name: "Desk lamp"}) if err == nil { light.OnChanged(onoff.ClusterID, func(attrs []uint32) { if slices.Contains(attrs, onoff.AttrOnOff) { slog.Info("light", slog.Bool("on", light.OnOff().OnOff())) // drive the real lamp here } }) err = n.Aggregator().Add(ctx, light) } if err != nil { _ = n.Stop(context.WithoutCancel(ctx)) return nil, err } n.Aggregator().MarkComplete() //nolint:contextcheck // the node's own; every device is added: stale endpoint numbers may go return n, nil }
Run it, scan the QR code or enter the manual code in a commissioner, and the light appears under the bridge:
go run ./examples/quickstart-device
It listens on UDP port 5540 and needs IPv6 and multicast on the LAN, like
any Matter device. A commissioner that filters by discriminator, which is
what Apple Home, Google Home and chip-tool do for a QR or manual code, finds
the device only through the subtype responder. Without the responder,
_matterc._udp lists the instance, but the discriminator subtype query
gets no answer.
Source: examples/quickstart-device/main.go, examples/quickstart-device/main_test.go, examples/reference-bridge/main.go:178-201, node/config.go:144-145.
Configuration¶
node.New calls Config.Validate first and refuses the whole configuration
with node.ErrInvalidConfig when any check fails. It checks:
- the discriminator fits 12 bits;
- the passcode is in range and not on the trivially-guessable list;
- the PBKDF iteration count and salt length are what SPAKE2+ accepts;
VendorID,ProductIDandNodeLabelare set;- a store is given (see below);
SupportedFabricsis at most 254;UniqueIDis at most 32 bytes;LocationCapabilityis a valid regulatory location;Network.ReannounceIntervalis not negative;OTA.Diris set whenOTAis.
The quickstart uses the CSA test vendor 0xFFF1. With no Attestation set,
the node serves the CSA test attestation chain and test Certification
Declaration for the configured vendor and product. Every commissioner
accepts that chain through its test PAA, and a product must not ship with
it. A product sets Config.Attestation to its own DAC, PAI and CD.
Defaults worth knowing:
| Field | Zero value means |
|---|---|
Network.Listen |
:5540 |
Advertiser |
mdns.NewNoop(): nothing is published, so nothing can discover the node |
DeviceTypeValidation |
matter.js's warn mode |
SupportedFabrics |
5 (matter.js defaults to 254) |
ProductDescription.Name |
BasicInformation.NodeLabel |
ProductDescription.DeviceType |
RootNode (0x0016) |
BasicInformation.UniqueID |
derived on the first boot and pinned in the store |
Locales |
en-US alone |
Logger |
slog.Default() |
Source: node/config.go:28-139, node/config.go:215-279, node/config.go:333-381, node/node.go:109-120.
Devices¶
The device/ package has one constructor per Matter device type in scope:
device.NewOnOffLight, device.NewDimmableLight, device.NewThermostat
and so on. They are generated from the matter.js model. A constructor
mounts every server cluster the device type mandates, with a default server
(the module's own where one exists, otherwise the generated spec.Server).
The endpoint assembler mounts Descriptor, Identify,
BridgedDeviceBasicInformation, Groups and ScenesManagement; the device
layer never does.
device.Options identifies the device:
StableKeyis required and unique. The assembler keeps the endpoint number under it, so the same key gets the same endpoint number after a restart. The quickstart's test checks this.Namebecomes the bridged device's NodeLabel.Vendoris its manufacturer.Reachableis a probe for the device's reachability; nil means always reachable.
Options passed after Options change what the device serves:
| Option | Effect |
|---|---|
WithServer(srv) |
your own server for a cluster, replacing the default |
WithOptional(id) |
mounts an optional cluster (Groups and ScenesManagement are then the assembler's) |
WithFeatures(id, bits) |
the features of a cluster's default server |
WithElements(id, opts) |
optional attributes, commands and events of a default server |
WithInitial(id, values) |
initial attribute values of a default server |
WithMeasurement(src) |
a sensor's measurement clusters from your live reading |
WithChangeNotifier(n) |
tells the bridge your model changed state its servers read |
WithClientClusters(ids...) |
client clusters beyond the mandatory ones |
Two directions of state¶
A device's state moves both ways, and the two directions use different calls:
- Controller to device.
Device.OnChanged(clusterID, fn)callsfnwith the attribute ids that changed. The quickstart uses it to log the light's new state. To refuse a command, for example when the physical lamp did not switch, give the light your ownonoff.Serverwith aSink(onoff.Config.SinkthroughWithServer). An error from the sink answers FAILURE and stores nothing. - Device to controller. When the lamp is switched at the wall, call
light.OnOff().Set(on). The value is stored and reported to subscribers, and the sink is not called.Setis safe from any goroutine, including from inside the sink.
Source: device/doc.go:4-27, device/options.go:18-43, device/options.go:91-215, device/device.go:643-661, cluster/onoff/server.go:54-72, cluster/onoff/server.go:749-768.
The Aggregator¶
Node.Aggregator() is the Aggregator endpoint's set of bridged devices.
- Before
Start,AddandRemoveonly change the set. The first assembly happens atStart. - After
Start, eachAddandRemovereassembles the running bridge. - An endpoint without a
StableKeyis refused withErrNoStableKey. A second endpoint with the same key is refused withErrDuplicateKey.
Call MarkComplete once every device you know about is added. Until then
the node keeps the stored endpoint numbers of devices that are absent.
Afterwards, a stored device missing from the set loses its number. A host
whose model loads asynchronously calls MarkComplete after the load. If it
is called too early, the endpoint numbers of devices not yet loaded are
garbage-collected at boot, and every controller sees those devices as new.
Source: node/aggregator.go:40-132, examples/reference-bridge/main.go:328-332.
Commissioning¶
Node.PairingCodes returns the QR payload and the 11-digit manual code for
the configured passcode and discriminator. When Start loads no fabric from
the store, it opens the node's own commissioning window. A commissioned node opens
a window only when a controller asks for one through
AdministratorCommissioning, or when you call Node.OpenBasicWindow with
fresh credentials.
Node.SetCommissioningBlocked(ctx, err) refuses every new commissioning
while err is non-nil, and controllers that are already paired keep
working. A host calls it before Start when, for example, it cannot serve
its configured attestation chain.
Source: node/node.go:691-712, node/node.go:526-534, node/commissioning.go:426-454, node/node.go:641-662.
Persistence¶
Config.Store.DB is a *sql.DB that you open; no library package imports
a SQLite driver. The quickstart uses modernc.org/sqlite, the pure-Go driver the
module already depends on. node.New applies both schemas (fabrics,
credentials, ACL, groups, subscriptions, settings, and endpoint numbers) to
that handle on every boot; both scripts are idempotent.
Open the database with _pragma=foreign_keys(1) in the DSN. The fabric
tables cascade-delete through foreign keys, and SQLite ignores those
silently with the pragma off. The pragma must be in the DSN, not in a
statement, so that every pooled connection gets it.
The persistence guide lists every table and settings key.
Source: node/config.go:281-290, node/node.go:300-317, examples/reference-bridge/persist.go:24-34.
Stopping¶
Node.Run(ctx) starts the node, waits for ctx to end, and stops it with a
5-second budget. Node.Stop runs the shutdown in a fixed order:
- the OTA requestor stops;
- the operational time is stored and the ShutDown event is emitted;
- the bindings and client stack close;
- PASE, the commissioning window and the CASE reaper stop;
- the subscription engine, the group sender, BDX and the re-announce loop stop;
- the bridge stops (sessions are closed with a CloseSession to each peer, then the socket closes and the mDNS records are withdrawn);
- the advertiser closes.
Stop is idempotent and safe without Start. A stopped node does not
restart: Start after Stop returns node.ErrStopped. To restart, build a
new node on the same store.
New already starts the subscription engine and the session reapers. Call
Stop on every node that New returned, including one you never started.
The quickstart does this on its error paths.
Source: node/node.go:34-36, node/node.go:538-599, node/node.go:95-108, bridge/bridge.go:1541-1556.
What the reference daemon adds¶
examples/reference-bridge is the facade plus the host's own pieces, each
of which is something a product would also decide:
- Flags for the listen address, TCP, passcode, discriminator, PBKDF parameters, label and log level. Each flag is checked before the node is built.
- A vendor attestation chain read from a directory instead of the test chain.
- The OTA requestor with an applier that validates and keeps the image.
This is the host's
OtaApplier. - An Actions server on the Aggregator, root FixedLabels and two locales.
- A fleet of simulated devices, one per surface the module serves. The blind's position is persisted in the settings table.
- Testing-only flags for certification runs: the app pipe, the TestEventTrigger key, the OTA provider and its ledger check, the simulated Wi-Fi link and the media devices. None of these belongs in a product.
Source: examples/reference-bridge/main.go:4-24, examples/reference-bridge/main.go:96-145, examples/reference-bridge/main.go:147-299, examples/reference-bridge/main.go:306-391, examples/reference-bridge/persist.go:68-86.