diff --git a/cli/commission.go b/cli/commission.go index 066bcfa..12a3eb4 100644 --- a/cli/commission.go +++ b/cli/commission.go @@ -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 diff --git a/cli/output/tree.go b/cli/output/tree.go index f62668a..eed71ee 100644 --- a/cli/output/tree.go +++ b/cli/output/tree.go @@ -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" ) @@ -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) @@ -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: @@ -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) diff --git a/cli/output/tree_d2.go b/cli/output/tree_d2.go index ed1ce8e..e5369cf 100644 --- a/cli/output/tree_d2.go +++ b/cli/output/tree_d2.go @@ -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" @@ -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") diff --git a/cli/output/tree_d2_test.go b/cli/output/tree_d2_test.go index 445341d..4982ffe 100644 --- a/cli/output/tree_d2_test.go +++ b/cli/output/tree_d2_test.go @@ -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:") diff --git a/cli/tree.go b/cli/tree.go index 6ad4417..7c4440a 100644 --- a/cli/tree.go +++ b/cli/tree.go @@ -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). diff --git a/internal/clusters/basicinformation/specversion.go b/internal/clusters/basicinformation/specversion.go new file mode 100644 index 0000000..88cc81a --- /dev/null +++ b/internal/clusters/basicinformation/specversion.go @@ -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) +} diff --git a/internal/clusters/basicinformation/specversion_test.go b/internal/clusters/basicinformation/specversion_test.go new file mode 100644 index 0000000..dfb225a --- /dev/null +++ b/internal/clusters/basicinformation/specversion_test.go @@ -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) + } + } +} diff --git a/internal/commissioning/flow.go b/internal/commissioning/flow.go index 8a199ba..4b7e18a 100644 --- a/internal/commissioning/flow.go +++ b/internal/commissioning/flow.go @@ -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. @@ -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. diff --git a/internal/commissioning/flow_test.go b/internal/commissioning/flow_test.go index 04e3161..76ae933 100644 --- a/internal/commissioning/flow_test.go +++ b/internal/commissioning/flow_test.go @@ -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) + } +} diff --git a/internal/store/types.go b/internal/store/types.go index d1a063e..7d34e8b 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -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.