Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions cli/commission.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,11 +351,14 @@ func nextNodeID() (uint64, error) {
// buildNodeFromResult creates a store.Node populated from the commissioning result.
func buildNodeFromResult(nodeID uint64, result *commissioning.CommissioningResult) *store.Node {
node := &store.Node{
ID: nodeID,
VendorID: result.VendorID,
ProductID: result.ProductID,
LastAddress: result.Address,
LastSeen: time.Now(),
ID: nodeID,
VendorID: result.VendorID,
ProductID: result.ProductID,
SpecificationVersion: result.SpecificationVersion,
SoftwareVersion: result.SoftwareVersion,
SerialNumber: result.SerialNumber,
LastAddress: result.Address,
LastSeen: time.Now(),
}
if result.ProductName != "" {
node.Name = result.ProductName
Expand Down
40 changes: 27 additions & 13 deletions cli/output/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"strings"

"github.com/p0fi/matter-cli/internal/clusters/basicinformation"
"github.com/p0fi/matter-cli/internal/store"
"github.com/p0fi/matter-cli/internal/vendordb"
)
Expand Down Expand Up @@ -90,10 +91,10 @@ func FormatTree(w io.Writer, node *store.Node) error {
name = "Unnamed"
}
fmt.Fprintf(w, "%s %s\n", Bold(name), Muted(fmt.Sprintf("(Node %d)", node.ID)))
fmt.Fprintf(w, " %s %s\n", Label("Vendor:"), Accent(vendordb.FormatVendorID(node.VendorID)))
fmt.Fprintf(w, " %s %s\n", Label("Product:"), Accent(fmt.Sprintf("0x%04X", node.ProductID)))
fmt.Fprintf(w, " %s %s\n", Label(fmt.Sprintf("%-11s", "Vendor:")), Accent(vendordb.FormatVendorID(node.VendorID)))
fmt.Fprintf(w, " %s %s\n", Label(fmt.Sprintf("%-11s", "Product ID:")), Accent(fmt.Sprintf("0x%04X", node.ProductID)))
if node.LastAddress != "" {
fmt.Fprintf(w, " %s %s\n", Label("Address:"), Value(node.LastAddress))
fmt.Fprintf(w, " %s %s\n", Label(fmt.Sprintf("%-11s", "Address:")), Value(node.LastAddress))
}
fmt.Fprintln(w)

Expand Down Expand Up @@ -166,13 +167,16 @@ type TreeEndpoint struct {

// TreeData is the full device tree data passed to FormatRichTree.
type TreeData struct {
NodeID uint64
NodeName string
VendorID uint16
ProductID uint16
LastAddress string
Endpoints []TreeEndpoint
Level int
NodeID uint64
NodeName string
VendorID uint16
ProductID uint16
SpecificationVersion uint32
SoftwareVersion uint32
SerialNumber string
LastAddress string
Endpoints []TreeEndpoint
Level int
}

// FormatRichTree renders the device tree with depth controlled by data.Level:
Expand All @@ -191,10 +195,20 @@ func FormatRichTree(w io.Writer, data *TreeData) error {
name = "Unnamed"
}
fmt.Fprintf(w, "%s %s\n", Bold(name), Muted(fmt.Sprintf("(Node %d)", data.NodeID)))
fmt.Fprintf(w, " %s %s\n", Label("Vendor:"), Accent(vendordb.FormatVendorID(data.VendorID)))
fmt.Fprintf(w, " %s %s\n", Label("Product:"), Accent(fmt.Sprintf("0x%04X", data.ProductID)))
lbl := func(s string) string { return Label(fmt.Sprintf("%-14s", s)) }
fmt.Fprintf(w, " %s %s\n", lbl("Vendor:"), Accent(vendordb.FormatVendorID(data.VendorID)))
fmt.Fprintf(w, " %s %s\n", lbl("Product ID:"), Accent(fmt.Sprintf("0x%04X", data.ProductID)))
if sv := basicinformation.FormatSpecVersion(data.SpecificationVersion); sv != "" {
fmt.Fprintf(w, " %s %s\n", lbl("Spec Version:"), Value(sv))
}
if data.SoftwareVersion != 0 {
fmt.Fprintf(w, " %s %s\n", lbl("FW ver:"), Value(fmt.Sprintf("%d", data.SoftwareVersion)))
}
if data.SerialNumber != "" {
fmt.Fprintf(w, " %s %s\n", lbl("Serial Number:"), Value(data.SerialNumber))
}
if data.LastAddress != "" {
fmt.Fprintf(w, " %s %s\n", Label("Address:"), Value(data.LastAddress))
fmt.Fprintf(w, " %s %s\n", lbl("Address:"), Value(data.LastAddress))
}
fmt.Fprintln(w)

Expand Down
18 changes: 16 additions & 2 deletions cli/output/tree_d2.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"strings"

"github.com/p0fi/matter-cli/internal/clusters/basicinformation"
"github.com/p0fi/matter-cli/internal/vendordb"
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
Expand Down Expand Up @@ -128,8 +129,21 @@ func buildD2Script(data *TreeData) string {
name = "Unnamed"
}

// Node container label includes vendor/product info.
nodeLabel := fmt.Sprintf("%s · ProductID: 0x%04X", vendordb.FormatVendorID(data.VendorID), data.ProductID)
// Node container label includes vendor/product info and optional device details.
nodeLabel := fmt.Sprintf("%s · Product ID: 0x%04X", vendordb.FormatVendorID(data.VendorID), data.ProductID)
var details []string
if sv := basicinformation.FormatSpecVersion(data.SpecificationVersion); sv != "" {
details = append(details, "Spec Version: "+sv)
}
if data.SoftwareVersion != 0 {
details = append(details, fmt.Sprintf("FW %d", data.SoftwareVersion))
}
if data.SerialNumber != "" {
details = append(details, "Serial Number: "+data.SerialNumber)
}
if len(details) > 0 {
nodeLabel += "\n" + strings.Join(details, " · ")
}

fmt.Fprintf(&sb, "%s: %q {\n", d2SafeKey(name), nodeLabel)
fmt.Fprintf(&sb, " grid-columns: 2\n")
Expand Down
2 changes: 1 addition & 1 deletion cli/output/tree_d2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestBuildD2Script_Level1(t *testing.T) {

assert.Contains(t, script, "Kitchen Light")
assert.Contains(t, script, "0x1234")
assert.Contains(t, script, "ProductID: 0x5678")
assert.Contains(t, script, "Product ID: 0x5678")
// Nested: endpoints inside node container
assert.Contains(t, script, "ep0:")
assert.Contains(t, script, "ep1:")
Expand Down
15 changes: 9 additions & 6 deletions cli/tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,15 @@ func openFile(path string) error {
// lists and, optionally, attribute values from the device.
func buildTreeData(ctx context.Context, w io.Writer, node *store.Node, level int, verbose bool) (*output.TreeData, error) {
data := &output.TreeData{
NodeID: node.ID,
NodeName: node.Name,
VendorID: node.VendorID,
ProductID: node.ProductID,
LastAddress: node.LastAddress,
Level: level,
NodeID: node.ID,
NodeName: node.Name,
VendorID: node.VendorID,
ProductID: node.ProductID,
SpecificationVersion: node.SpecificationVersion,
SoftwareVersion: node.SoftwareVersion,
SerialNumber: node.SerialNumber,
LastAddress: node.LastAddress,
Level: level,
}

// Populate basic structure from store data (always needed).
Expand Down
23 changes: 23 additions & 0 deletions internal/clusters/basicinformation/specversion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright 2026 matter-cli contributors
// SPDX-License-Identifier: Apache-2.0

package basicinformation

import "fmt"

// FormatSpecVersion converts a packed SpecificationVersion uint32 (e.g.
// 0x01030000 for Matter 1.3) into a human-readable string ("1.3"). The minor
// component is always included; a trailing zero patch component is omitted.
// Returns an empty string for zero.
func FormatSpecVersion(v uint32) string {
if v == 0 {
return ""
}
major := (v >> 24) & 0xFF
minor := (v >> 16) & 0xFF
patch := (v >> 8) & 0xFF
if patch != 0 {
return fmt.Sprintf("%d.%d.%d", major, minor, patch)
}
return fmt.Sprintf("%d.%d", major, minor)
}
26 changes: 26 additions & 0 deletions internal/clusters/basicinformation/specversion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2026 matter-cli contributors
// SPDX-License-Identifier: Apache-2.0

package basicinformation

import "testing"

func TestFormatSpecVersion(t *testing.T) {
tests := []struct {
v uint32
want string
}{
{0x00000000, ""},
{0x01030000, "1.3"},
{0x01040000, "1.4"},
{0x01000000, "1.0"},
{0x01030100, "1.3.1"},
{0x02010200, "2.1.2"},
}
for _, tt := range tests {
got := FormatSpecVersion(tt.v)
if got != tt.want {
t.Errorf("FormatSpecVersion(0x%08X) = %q, want %q", tt.v, got, tt.want)
}
}
}
24 changes: 18 additions & 6 deletions internal/commissioning/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,15 @@ type Commissioner struct {

// CommissioningResult contains device information gathered during commissioning.
type CommissioningResult struct {
VendorName string
VendorID uint16
ProductName string
ProductID uint16
Address string // host:port used to reach the device
Endpoints []EndpointInfo // discovered endpoints and their clusters
VendorName string
VendorID uint16
ProductName string
ProductID uint16
SpecificationVersion uint32 // Matter spec revision (e.g. 0x01030000 = 1.3); zero on pre-1.3 devices
SoftwareVersion uint32 // firmware version reported by the device
SerialNumber string // optional per-spec; empty if the device doesn't expose it
Address string // host:port used to reach the device
Endpoints []EndpointInfo // discovered endpoints and their clusters
}

// EndpointInfo describes a single endpoint discovered via the Descriptor cluster.
Expand Down Expand Up @@ -623,6 +626,15 @@ func (c *Commissioner) readDeviceInfo(ctx context.Context, session Session, resu
if data, err := c.Client.ReadAttribute(ctx, session, 0, basicInfo, 0x0004); err == nil {
result.ProductID = decodeTLVUint16(data)
}
if data, err := c.Client.ReadAttribute(ctx, session, 0, basicInfo, 0x0009); err == nil {
result.SoftwareVersion = decodeTLVUint32(data)
}
if data, err := c.Client.ReadAttribute(ctx, session, 0, basicInfo, 0x000F); err == nil {
result.SerialNumber = decodeTLVString(data)
}
if data, err := c.Client.ReadAttribute(ctx, session, 0, basicInfo, 0x0015); err == nil {
result.SpecificationVersion = decodeTLVUint32(data)
}

// Read endpoint structure from Descriptor cluster.
// PartsList (attr 0x0003) on endpoint 0 lists all non-root endpoints.
Expand Down
74 changes: 74 additions & 0 deletions internal/commissioning/flow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1084,3 +1084,77 @@ func TestCommissioner_Commission_Cancelled(t *testing.T) {
t.Fatal("expected error for cancelled context")
}
}

// TestReadDeviceInfo_NewAttributes verifies that readDeviceInfo reads
// SpecificationVersion, SoftwareVersion, and SerialNumber from the device
// and populates the result correctly.
func TestReadDeviceInfo_NewAttributes(t *testing.T) {
const basicInfo = uint32(0x0028)

swVerTLV := encodeTLVUint32(42)
specVerTLV := encodeTLVUint32(0x01030000)

serialW := tlv.NewWriter()
_ = serialW.PutUTF8String(tlv.AnonymousTag(), "SN-ABC123")
serialTLV := serialW.Bytes()

c := newTestCommissioner()
mc := c.Client.(*mockInteractionClient)
mc.readOverrides = map[attrKey]struct {
data []byte
err error
}{
{endpoint: 0, cluster: basicInfo, attribute: 0x0009}: {data: swVerTLV},
{endpoint: 0, cluster: basicInfo, attribute: 0x000F}: {data: serialTLV},
{endpoint: 0, cluster: basicInfo, attribute: 0x0015}: {data: specVerTLV},
}

result := &CommissioningResult{}
session := &mockSession{}
c.readDeviceInfo(context.Background(), session, result)

if result.SoftwareVersion != 42 {
t.Errorf("SoftwareVersion: got %d, want 42", result.SoftwareVersion)
}
if result.SerialNumber != "SN-ABC123" {
t.Errorf("SerialNumber: got %q, want %q", result.SerialNumber, "SN-ABC123")
}
if result.SpecificationVersion != 0x01030000 {
t.Errorf("SpecificationVersion: got 0x%08X, want 0x01030000", result.SpecificationVersion)
}
}

// TestReadDeviceInfo_MissingAttributes verifies that readDeviceInfo does not
// fail when SpecificationVersion, SoftwareVersion, or SerialNumber are absent
// (pre-1.3 or serial-less devices).
func TestReadDeviceInfo_MissingAttributes(t *testing.T) {
c := newTestCommissioner()
// Override only the three new optional attributes to return
// UNSUPPORTED_ATTRIBUTE, simulating a device that supports the mandatory
// BasicInformation attributes but not SoftwareVersion, SerialNumber, or
// SpecificationVersion.
mc := c.Client.(*mockInteractionClient)
unsupported := fmt.Errorf("UNSUPPORTED_ATTRIBUTE")
mc.readOverrides = map[attrKey]struct {
data []byte
err error
}{
{0, 0x0028, 0x0009}: {err: unsupported}, // SoftwareVersion
{0, 0x0028, 0x000F}: {err: unsupported}, // SerialNumber
{0, 0x0028, 0x0015}: {err: unsupported}, // SpecificationVersion
}

result := &CommissioningResult{}
session := &mockSession{}
c.readDeviceInfo(context.Background(), session, result)

if result.SpecificationVersion != 0 {
t.Errorf("SpecificationVersion should be zero for pre-1.3 device, got %d", result.SpecificationVersion)
}
if result.SoftwareVersion != 0 {
t.Errorf("SoftwareVersion should be zero when absent, got %d", result.SoftwareVersion)
}
if result.SerialNumber != "" {
t.Errorf("SerialNumber should be empty when absent, got %q", result.SerialNumber)
}
}
19 changes: 11 additions & 8 deletions internal/store/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@ type Fabric struct {

// Node represents a commissioned Matter device within a fabric.
type Node struct {
ID uint64 `json:"id"`
FabricID uint64 `json:"fabric_id"`
Name string `json:"name"`
VendorID uint16 `json:"vendor_id"`
ProductID uint16 `json:"product_id"`
Endpoints []Endpoint `json:"endpoints"`
LastAddress string `json:"last_address"`
LastSeen time.Time `json:"last_seen"`
ID uint64 `json:"id"`
FabricID uint64 `json:"fabric_id"`
Name string `json:"name"`
VendorID uint16 `json:"vendor_id"`
ProductID uint16 `json:"product_id"`
SpecificationVersion uint32 `json:"specification_version,omitempty"`
SoftwareVersion uint32 `json:"software_version,omitempty"`
SerialNumber string `json:"serial_number,omitempty"`
Endpoints []Endpoint `json:"endpoints"`
LastAddress string `json:"last_address"`
LastSeen time.Time `json:"last_seen"`
}

// Endpoint represents a single endpoint on a Matter node.
Expand Down