matter
Matter
Matter protocol implemented in pure Crystal Lang.
Installation
-
Add the dependency to your
shard.yml:dependencies: matter: github: Crystal-Matter/matter -
Run
shards install
Architecture
The library is a stack of layers, each one depending only on the layers below it. src/matter.cr requires them in that order, one aggregator per subsystem, with no globs.
| Layer | Namespace | What lives there |
|---|---|---|
| Device | Matter::Device |
the runtime and the declarative DSL: lifecycle, advertisements, persistence |
| Node | Matter::Node, Matter::Endpoint, Matter::EventJournal |
endpoints, the clusters they own, the flat cluster index, the event rings |
| Clusters | Matter::Cluster |
35 clusters built on the cluster DSL, plus Cluster::Registry |
| Protocol | Matter::Protocol |
MessageHandler and the objects it routes through, the interaction model handler, subscriptions |
| Commissioning | Matter::Commissioning |
the failsafe, commissioning window and credential services |
| Session | Matter::Session |
PASE and CASE, SecureContext, SecureMessage framing |
| Transport | Matter::Transport |
the UDP transport, exchanges, message counters |
| Discovery | Matter::MDNS |
the responder that answers commissionable and operational queries |
| Storage | Matter::Storage |
backends, documents, records, the migrator — depends on nothing else |
| Primitives | Matter::Codec, Crypto, Certificate, Datatype, InteractionModel, Error |
the wire codec, cryptography, certificates, id wrappers, statuses and paths |
require "matter" gives you all of that: it is the device library, and it has no controller in it. Commissioning other devices — the mDNS scanner, the secure client, PASE/CASE pairing and the commissioning flows — lives behind a second entrypoint:
require "matter" # build a device
require "matter/controller" # also commission and talk to other devices
Two more modules stay out of the default tree and are required explicitly: matter/storage/legacy (the 0.1 storage importer) and matter/storage/cli (the matter-storage binary). docs/architecture.md follows a datagram through the whole stack.
Building a device
A device is a Matter::Device subclass that declares what it is. This is the switch example in full — examples/matter_switch_device.cr is the same class with an interactive console attached:
require "matter"
# Development ids. Ship with a vendor and product id allocated by the CSA.
SWITCH_PRODUCT_ID = 0x8001_u16
class SwitchDevice < Matter::Device
identity vendor: "Spider-Gazelle", product: "Crystal Switch",
vendor_id: Matter::SetupPayload.test_vendor_id,
product_id: SWITCH_PRODUCT_ID,
discriminator: Matter::SetupPayload.generate_random_discriminator,
pin: Matter::SetupPayload.generate_random_pin,
device_type: Matter::DeviceType::ON_OFF_LIGHT,
appearance: :satin
storage yaml: "matter_switch_storage.yml"
endpoint 1, device_type: Matter::DeviceType::ON_OFF_LIGHT do
cluster Matter::Cluster::OnOff, feature_map: :lighting, as: :switch
cluster Matter::Cluster::Identify, identify_type: :visible_light
cluster Matter::Cluster::Groups
cluster Matter::Cluster::ScenesManagement
end
on(:switch, :state_changed) do |state|
puts "switch is now #{state ? "on" : "off"}"
end
end
device = SwitchDevice.new
device.start # returns as soon as the device is advertising
device.await_shutdown # blocks; call device.shutdown! to flush storage and stop
The declarations are:
identity— vendor, product, ids, discriminator, setup pin and primary device type, plus the optionalappearance:, hardware/software versions,serial_number:andunique_id:. Each value is evaluated once into a constant, so a randomised id stays stable for the life of the process.storage yaml: "path"/storage json: "path"/storage :memory— generates the constructor that opens the backend. EveryDevice#initializeargument stays available, so a spec can pass its own backend:SwitchDevice.new(Matter::Storage::Memory.new).network :ethernet | :wifi | :thread— the Network Commissioning cluster's type and feature map.endpoint number, device_type: do ... end— an endpoint and, inside the block, oneclusterdeclaration per cluster.as: :switchadds a typed accessor (switch : Cluster::OnOff). The endpoint is validated against its device types when it is registered, so a device type missing one of its mandatory clusters is an error at start-up rather than a controller complaining later.endpoint_template :name, device_type:, parameters: {...} do ... end— the shape of an endpoint added at runtime withadd_endpoint(:name, key: value). Template parameters are persisted, so dynamic endpoints survive a restart.examples/matter_bridge_device.cruses this.on(:accessor, :event) { |...| }— binds a block to a cluster callback. Callbacks are wired once every endpoint exists, so one may reference an accessor on another endpoint.
The DSL rules are documented at the top of src/matter/device/dsl.cr, and the cluster DSL that backs the cluster classes at the top of src/matter/cluster/dsl.cr.
Cluster values
Cluster reads return TLV::Any or an interaction model status. Attribute writes take TLV::Any; command requests and CommandResponse#response use TLV::Any?. Pass TLV::Any.new(50_u8) to write a scalar or request.to_tlv(nil) for a TLV::Serializable command struct. Wire encoding stays in the protocol layer.
Examples
Ten example devices live in ./examples, all written on the DSL.
- Build one with
crystal build examples/matter_switch_device.cr -o bin/matter_switch - Validate with chip-tool
- Launch
bin/matter_switch, grab the chip-tool command line in the output - Clear any previous sessions:
chip-tool storage clear-all - Commission using chip-tool
- Launch
Confirmed working with iOS.
examples/matter_level_control_device.cr exposes a dimmable light with Groups on endpoint 1, an extended color light on endpoint 2, and a window covering on endpoint 3. The Docker e2e suite commissions it with the official chip-tool and exercises group membership, hue, color temperature, and lift position commands.
Example projects
These projects show off how custom matter interfaces can be built for almost any device.
- Control a Home Connect oven using siri
- Control a rangehood
- Control a Windows media center PC, plugged into your TV, from your phone
Controller
require "matter/controller" adds the commissioner side: discovery, pairing and an interaction model client. examples/chip-tool.cr is a full CLI built on it — see examples/chip-tool/README.md.
Find commissionable devices:
require "matter/controller"
DISCOVERY_WINDOW = 2.seconds
scanner = Matter::Controller::Scanner.new
scanner.start
scanner.query_commissioning
sleep DISCOVERY_WINDOW
scanner.commissioning_devices.each do |found|
puts "discriminator #{found.discriminator} at #{found.addresses.first?}"
end
scanner.close
Commission one, then read an attribute over the operational session. Controller state — the fabric, its certificates, the known nodes and the unsecured message counter — lives in a storage backend, so the counter has to be written back before the process exits:
require "matter/controller"
NODE_ID = 0x1_u64
ENDPOINT = 1_u16
PAIRING_CODE = "1495-753-5193"
store = Matter::Controller::StateStore.new(Matter::Storage::YamlFile.new("controller.yml"))
state = store.load
client = Matter::Controller::Client.new(
unsecured_source_node_id: state.commissioner_node_id,
initial_unsecured_message_counter: state.unsecured_message_counter
)
begin
# Discovers the device over mDNS from the discriminator in the pairing code,
# runs PASE, installs the fabric's credentials and stores the node.
commissioner = Matter::Controller::Commissioning::Commissioner.new(store, client)
commissioner.pairing_code(NODE_ID, PAIRING_CODE)
commissioner.close
state = store.load
fabric = state.fabric || raise Matter::CommissioningError.new("no controller fabric")
node = state.nodes[NODE_ID]
peer = Socket::IPAddress.new(node.address || "::1", node.port)
session = Matter::Controller::Pairing::CasePairing.new
.pair(client, peer, fabric, peer_node_id: NODE_ID)
im = Matter::Controller::ImClient.new(client)
report = im.read_attribute(
session: session, peer: peer,
endpoint_id: ENDPOINT,
cluster_id: Matter::Cluster::OnOff::CLUSTER_ID,
attribute_id: Matter::Cluster::OnOff::ATTR_ON_OFF
)
report.attribute_reports.try &.each do |attribute_report|
puts attribute_report.attribute_data.try &.data.try &.value
end
im.invoke(
session: session, peer: peer,
endpoint_id: ENDPOINT,
cluster_id: Matter::Cluster::OnOff::CLUSTER_ID,
command_id: Matter::Cluster::OnOff::CMD_TOGGLE
)
ensure
state.unsecured_message_counter = client.transport.message_counter.counter
store.save(state)
client.close
end
ImClient also has write_attribute, write_attributes and invoke; Commissioning::CommissioningWindowOpener opens an enhanced commissioning window on an already commissioned node and hands back a manual pairing code for it.
Error handling
Everything the library raises on its own behalf descends from Matter::Error:
| Error | Raised for |
|---|---|
CodecError |
malformed messages, TLV and DER |
CryptoError / AuthenticationError |
key and cipher failures; a failed decrypt or signature |
CertificateError |
attestation and operational certificate problems |
StorageError |
an unreadable, closed or newer-schema store |
SessionError |
missing, expired or exhausted sessions |
ProtocolError / TimeoutError |
exchange and interaction model failures |
CommissioningError |
failsafe, window and credential failures |
TransportError |
socket and datagram failures |
ConfigurationError |
a device or cluster declared incorrectly |
ClusterError |
carries an interaction model status back to the wire |
Nothing raises a bare string, so rescue error : Matter::StorageError is a reliable way to react to one class of failure. Inside a cluster you rarely need to: an exception escaping a command handler or an attribute write is converted to an interaction model status at the boundary, so a hostile payload cannot make the device drop the exchange.
Statuses are built with factories rather than enum literals, one per usable status code:
require "matter"
status = Matter::InteractionModel::Status.success
status = Matter::InteractionModel::Status.unsupported_attribute
status = Matter::InteractionModel::Status.constraint_error
# `Failure` carrying a cluster-specific code
status = Matter::InteractionModel::Status.cluster_failure(
Matter::Cluster::DoorLock::StatusCode::InvalidField
)
Logging
Log sources mirror the file tree, so a subsystem can be turned up on its own: matter.session.pase, matter.protocol.message_handler, matter.transport.udp_transport, matter.cluster.level_control. A file without its own source falls back to its namespace (matter.storage, matter.mdns), and a spec fails if a source ever drifts from its path.
Examples log at info by default; set MATTER_LOG to change the level (specs use MATTER_SPEC_LOG the same way, defaulting to warn):
MATTER_LOG=debug bin/matter_switch
MATTER_SPEC_LOG=trace crystal spec
Applications wire this up themselves — Log.setup with a Log::Builder if a single level is not enough:
require "matter"
Log.setup do |config|
config.bind "matter.*", :info, Log::IOBackend.new
config.bind "matter.session.*", :debug, Log::IOBackend.new
end
Storage
Device state (fabrics, sessions, subscriptions, cluster state, identity) is persisted through Matter::Storage::Backend. Three backends ship with the library:
Matter::Storage::YamlFile- one human readable YAML file (recommended)Matter::Storage::JsonFile- the same layout as JSONMatter::Storage::Memory- nothing persisted; state is rebuilt on every start
A store is a set of collections (fabrics, sessions, subscriptions, device, clusters, app, meta), each holding documents keyed by id, so a file looks like {collection: {id: document}}. Documents hold 64-bit integers, floats, booleans, strings, byte strings, timestamps, arrays and nested maps. Bytes are written as !!binary base64 in YAML and {"$bytes": "<base64>"} in JSON; timestamps as !!timestamp / {"$time": "<RFC 3339 UTC>"}. meta/schema records the layout version.
Writes are debounced through Matter::Device::Persistence into a single transaction, and Device#shutdown! flushes them, so cluster state survives a crash and not only a clean shutdown. persistence.reset! factory-resets a device.
bin/matter-storage (built with shards build matter-storage) copies and prints stores. Store URIs are memory:, yaml:<path>, json:<path>:
bin/matter-storage migrate --from json:matter_storage.json --to yaml:matter_storage.yml
bin/matter-storage inspect yaml:matter_storage.yml # keys, certs and byte strings are redacted
Devices that ran a release before the storage refactor wrote a different flat JSON file ({"<context>": {"<key>": <json string>}}). Convert it once with the legacy: scheme:
bin/matter-storage migrate --from legacy:matter_storage.json --to yaml:matter_storage.yml
Unrecognised entries are kept under app/legacy-<context>-<key> and each is reported with a warning, so nothing is dropped silently. The importer lives in src/matter/storage/legacy/ and is only loaded by require "matter/storage/legacy" (the CLI does this); it is a temporary module that will be removed once deployed devices have migrated.
End to end tests
The full suite (unit specs + chip-tool driven end-to-end specs against every example device) runs inside docker compose, so the only requirement is Docker with compose v2 (Docker Desktop on macOS, or docker engine 27+ on Linux):
./test # build images, run everything, tear down, exit 0/1
./test --e2e-only # skip the unit specs
./test --unit-only # only the unit specs
./test -- e2e/spec/switch_spec.cr # a single e2e spec file
docker-compose.ymlstarts one container perexamples/matter_*_device.crand atestscontainer built from the official chip-tool image with crystal added.e2e/spec/*_spec.crare regular crystal specs that commission each device withchip-tool pairing code(mDNS discovery over the compose network) and then read and write the relevant cluster attributes.- Container logs (device output, pairing codes, protocol debug logs) are saved to
tmp/e2e/logs/after every run.
Persistence is checked by restarting devices in place. The tests container has no docker socket, so E2E::Device#restart! creates a marker file /e2e/logs/<example>.restart on the shared log volume; e2e/device-entrypoint.sh (a small supervisor loop) removes it, sends SIGTERM to the device for a clean shutdown, appends === e2e restart <n> === to <example>.log and starts the binary again in the same working directory, where its storage lives. The spec then waits for the operational-mode banner after that line and checks that no new pairing code was printed before reading the persisted state back with chip-tool. A device that exits without a restart having been requested takes its container down with its exit code.
The same validation can be run directly on the host against a locally installed chip-tool:
./examples/run_validation.sh
We have our own implementation of chip-tool for running end-to-end tests too (used in the CI) Aiming to be compatible with the official tool
crystal build ./examples/chip-tool.cr -o ./bin/chip-tool --error-trace
./examples/run_validation.sh --chip-tool ./bin/chip-tool
Contributing
- Fork it (https://github.com/Crystal-Matter/matter/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Stephen von Takach - creator and maintainer
matter
- 10
- 0
- 0
- 5
- 7
- 3 days ago
- October 30, 2023
MIT License
Sat, 12 Sep 2026 03:33:01 GMT