Skip to content

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, ProductID and NodeLabel are set;
  • a store is given (see below);
  • SupportedFabrics is at most 254;
  • UniqueID is at most 32 bytes;
  • LocationCapability is a valid regulatory location;
  • Network.ReannounceInterval is not negative;
  • OTA.Dir is set when OTA is.

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:

  • StableKey is 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.
  • Name becomes the bridged device's NodeLabel. Vendor is its manufacturer.
  • Reachable is 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) calls fn with 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 own onoff.Server with a Sink (onoff.Config.Sink through WithServer). 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. Set is 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, Add and Remove only change the set. The first assembly happens at Start.
  • After Start, each Add and Remove reassembles the running bridge.
  • An endpoint without a StableKey is refused with ErrNoStableKey. A second endpoint with the same key is refused with ErrDuplicateKey.

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:

  1. the OTA requestor stops;
  2. the operational time is stored and the ShutDown event is emitted;
  3. the bindings and client stack close;
  4. PASE, the commissioning window and the CASE reaper stop;
  5. the subscription engine, the group sender, BDX and the re-announce loop stop;
  6. the bridge stops (sessions are closed with a CloseSession to each peer, then the socket closes and the mDNS records are withdrawn);
  7. 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.