Matter v1.6.1 more improvements (#25086)

This commit is contained in:
s-hadinger authored and GitHub committed 2026-09-29 12:46:50 +02:00
1 parent c4327653db
commit 4d4dd78be2
14 files changed
+1551 -54

No files matched your search

@@ -0,0 +1,223 @@
# Matter 1.6.1 Tasmota Profile and Groupcast Roadmap
Status: implementation profile and remaining-work guide
Last updated: 2026-09-29
Target: Tasmota Berry Matter implementation
## 1. Scope
The current work aligns core version metadata with Matter 1.6.1 and provides
the storage and management commands needed by Group Key Management.
Tasmota deliberately does not implement the full Matter Access Control engine.
The reason is practical: persistent ACL lists, CAT extraction, target matching,
list-write transactions, authorization checks on every Interaction Model path,
and ACL events add substantial code and state for little benefit in the common
Tasmota deployment model.
This document records that tradeoff so the simplified behavior is not mistaken
for a complete or certification-ready ACL implementation.
## 2. Fixed Access Control Policy
### 2.1 Exposed ACL
For an authenticated CASE session, the Access Control `ACL` attribute returns
one fabric-scoped entry:
```text
Privilege: Administer
AuthMode: CASE
Subjects: null
Targets: null
FabricIndex: accessing session's fabric
```
In Matter ACL semantics:
- `Subjects = null` matches every authenticated CASE subject on that fabric.
- `Targets = null` matches every endpoint, cluster, command, attribute, and
event.
- `Administer` is the highest privilege.
The result is equivalent to trusting every commissioned node on a fabric as a
full administrator.
### 2.2 Implementation Behavior
- The wildcard entry is generated when read; it is not stored in fabric JSON.
- ACL writes return `SUCCESS` but are discarded for commissioner
compatibility. Apple Home removes a newly commissioned fabric if its
post-commissioning ACL write is rejected.
- `AccessControlEntriesPerFabric` reports `1`.
- `SubjectsPerAccessControlEntry` and `TargetsPerAccessControlEntry` remain
readable because they are mandatory cluster attributes.
- No `AccessControlEntryChanged` event is emitted because the fixed entry
cannot change.
- No CAT values are extracted from NOCs or stored in CASE sessions.
- No Node ID, CAT, target, privilege, or command-specific ACL matching occurs.
- Existing CASE session persistence and resumption remain unchanged.
The successful no-op write is an explicit interoperability compromise. It lets
Apple Home finish provisioning, but makes the controller believe restrictions
were installed when they were not. Reads continue to return the fixed wildcard
entry, and all authenticated CASE peers on the fabric remain administrators.
### 2.3 Security Tradeoff
This profile is acceptable only when every node admitted to a fabric is
trusted with full device control.
Any fabric member with valid CASE credentials can potentially:
- Read and write any exposed attribute.
- Invoke administrative or operational commands.
- Change network configuration.
- Open commissioning windows.
- Modify fabrics or operational credentials where the existing cluster
implementation permits it.
- Configure Group Key Management data.
The policy does not grant access to devices outside the fabric. CASE
authentication still binds a session to a commissioned fabric, and the ACL
entry reports that fabric's `FabricIndex`.
### 2.4 Deliberate Matter Deviations
This profile does not implement:
- Writable per-fabric ACL lists.
- The normative minimum of four configurable ACL entries per fabric.
- Subject-specific Node ID restrictions.
- CASE Authenticated Tag matching or CAT version rules.
- Cluster, endpoint, or device-type targets.
- Group AuthMode ACL entries.
- Fabric-unfiltered ACL redaction rules.
- Access Control change events.
- ARL, AuxiliaryACL, or managed access restrictions.
Consequently, this profile must not claim full Access Control conformance or
Matter certification without a later standards review and implementation.
## 3. Current Group Key Management State
Implemented management-plane functionality:
- `GroupKeyMap`, `GroupTable`, `MaxGroupsPerFabric`, and
`MaxGroupKeysPerFabric` attributes.
- Fabric-scoped persistence of:
- GroupKeyMap associations.
- Groups endpoint membership.
- Group key sets and epoch start times.
- Mandatory commands:
- `KeySetWrite`
- `KeySetRead`
- `KeySetRemove`
- `KeySetReadAllIndices`
- Epoch key material is never returned by `KeySetRead`.
- Key length, epoch pairing, epoch ordering, policy, reserved-ID, and capacity
validation.
- Removing a key set also removes GroupKeyMap associations that reference it.
- Atomic GroupKeyMap list writes, including ListIndex-null append and
multi-message chunking.
## 4. Why Groups Remain Disabled
Group Key Management stores keys and associations; it does not receive or
decrypt multicast commands.
`Matter_Device.GROUP_TRANSPORT_READY` therefore remains `false` in production.
While it is false:
- `MaxGroupsPerFabric` reports zero.
- GroupKeyMap reads return an empty list.
- GroupKeyMap writes return `UNSUPPORTED_WRITE`.
- `AddGroup` fails.
This prevents a controller from provisioning a group that appears successful
but whose commands would never reach the device.
## 5. Required Group Message Data Plane
Before enabling `GROUP_TRANSPORT_READY`, implement:
1. Detection of group-secured Matter frames before unicast session lookup.
2. Fabric and key-set candidate resolution from Group Session ID.
3. Active epoch selection.
4. Group encryption-key and privacy-key derivation.
5. Privacy removal and AES-CCM authentication/decryption.
6. Per-sender replay-counter validation.
7. IPv6 multicast join/leave management.
8. Endpoint selection from persisted group membership.
9. Group-invokable command filtering.
10. Response suppression for multicast commands.
11. Message Counter Synchronization Protocol where required.
No application command may run before message authentication succeeds.
## 6. Access Policy for Future Group Transport
The fixed ACL entry uses `AuthMode = CASE`; it does not authorize
Group-authenticated messages.
Before group transport is enabled, choose and document one of these policies:
1. Add a second fixed wildcard Group ACL entry.
2. Implement a small dedicated GroupID admission policy.
3. Reintroduce a complete Matter ACL engine.
Option 1 is the smallest but gives every authenticated group broad access.
Option 2 can remain compact but is a Tasmota-specific policy. Option 3 is the
only path toward full Matter Access Control conformance.
Until that choice is implemented, group message dispatch must remain disabled.
## 7. Remaining Groupcast Work
Matter 1.6.1 Groupcast cluster `0x0065` is not implemented and must not be
advertised.
Required work includes:
- Membership and endpoint-map attributes.
- Group key association and update commands.
- Multicast policy.
- Listener and sender feature handling.
- Fabric removal cleanup.
- Derived-key cache invalidation.
- Counter persistence and replay recovery.
- Optional AuxiliaryACL behavior if the selected access policy requires it.
The Groupcast `GCAST` feature bit must remain clear until the complete receive
and dispatch path is operational.
## 8. Verification Before Enabling Groups
Minimum tests:
- Direct and wildcard GKM attribute reads.
- GroupKeyMap chunked list writes and rollback.
- Key-set write/read/remove/index commands.
- Key material redaction.
- Persistence and fabric isolation.
- Epoch rollover and mixed-width uint64 start times.
- Multicast address derivation and membership lifecycle.
- Valid and invalid group MICs.
- Duplicate and stale message counters.
- Endpoint membership filtering.
- Commands that are not group-invokable.
- No multicast response generation.
- Reboot and network-reconnect recovery.
## 9. Current Recommendation
For current Tasmota builds:
- Keep the fixed wildcard CASE/Administer ACL.
- Keep ACL writes as documented successful no-ops for commissioner
compatibility.
- Keep CAT and fine-grained ACL code out of the firmware.
- Keep Group Key Management storage and commands.
- Keep group provisioning fail-closed.
- Do not advertise Groupcast.
- Clearly document that all commissioned fabric members are fully trusted.
@@ -1,5 +1,14 @@
# Matter 1.4.1 Spec vs Tasmota Implementation — Gap Analysis & Change Spec
> **Tasmota ACL profile decision (2026-09-29):** Tasmota does not implement
> writable or fine-grained Matter ACLs. The Access Control cluster exposes one
> immutable, fabric-scoped entry with `Administer`, `CASE`, `Subjects = null`,
> and `Targets = null`. This grants every authenticated CASE peer on the fabric
> full access. ACL writes return `SUCCESS` but are discarded for Apple Home
> commissioning compatibility; CAT matching, target matching, ACL persistence,
> and Access Control change events are intentionally omitted. See
> `TASMOTA_FIXED_ACL_PROFILE.md` for the security and conformance tradeoffs.
## 1. Executive Summary
This document compares the Matter 1.4.1 Core Specification (March 2025) against the current Tasmota Berry implementation across the message layer, commissioning (PASE/CASE), and Interaction Model. The implementation is solid and functionally correct for a resource-constrained device. The gaps identified are primarily around **session parameter negotiation** (new mandatory fields in 1.4), **InteractionModelRevision** being outdated, and a few minor protocol conformance items. No architectural changes are recommended — the current layered design is clean and appropriate for the platform.
@@ -0,0 +1,71 @@
# Tasmota Fixed ACL Profile
Decision date: 2026-09-29
## Policy
Tasmota exposes one Access Control entry for the accessing fabric:
```text
Privilege = Administer
AuthMode = CASE
Subjects = null
Targets = null
FabricIndex = accessing fabric
```
This means every authenticated CASE peer on a commissioned fabric is treated
as a full administrator for every endpoint and cluster.
## Implementation
- The entry is generated for reads and is not persisted.
- ACL writes are acknowledged with `SUCCESS` but discarded. This is required
for Apple Home commissioning, which removes the newly commissioned fabric
when its post-commissioning ACL write is rejected.
- Reading ACL always returns the fixed wildcard entry, regardless of the last
value written by a commissioner.
- `AccessControlEntriesPerFabric` reports one.
- No CATs are extracted from NOCs or persisted in sessions.
- No Node ID, CAT, target, or privilege matching is performed.
- No Access Control change event is emitted because the entry is immutable.
## Rationale
Full Matter ACL support requires persistent per-fabric lists, chunked list
writes, subject and target validation, CAT processing, authorization checks on
all Interaction Model paths, migration handling, and ACL events. Tasmota
deliberately omits that complexity for code-size and maintenance reasons.
## Security Consequence
Every commissioned fabric member must be trusted with complete control of the
device. Compromise of any fabric member can expose all operations available to
that fabric.
The policy does not admit nodes outside the fabric: CASE authentication and
fabric binding are still required.
## Conformance Consequence
This is an intentional Matter deviation:
- ACL writes report success but do not change the effective or readable ACL.
- The normative configurable-entry minimum is not provided.
- Fine-grained subjects and targets are unavailable.
- CASE Authenticated Tags are unsupported.
- Group AuthMode ACL entries are unsupported.
- Access Control change events are unavailable.
Tasmota must not claim full Access Control conformance with this profile.
The successful no-op write is particularly important to document: a controller
can believe that its requested Node ID, CAT, target, or privilege restrictions
were installed, while Tasmota continues granting Administer access to every
authenticated CASE peer on that fabric.
## Group Transport
The fixed entry authorizes CASE only. It does not authorize Group-authenticated
messages. Group transport must remain disabled until a separate group access
policy is implemented or full ACL support is restored.
@@ -69,6 +69,11 @@ class Matter_Fabric : Matter_Expirable
# Admin info extracted from NOC/ICAC
var admin_subject
var admin_vendor
# Fabric-scoped Group Key Management data.
# Keep these as JSON-safe maps/lists so they are persisted with the fabric.
var group_key_map
var group_table
var group_key_sets
#############################################################
def init(store)
@@ -83,6 +88,9 @@ class Matter_Fabric : Matter_Expirable
self._counter_group_ctrl_snd_impl = matter.Counter()
self.counter_group_data_snd = self._counter_group_data_snd_impl.next() + self._GROUP_SND_INCR
self.counter_group_ctrl_snd = self._counter_group_data_snd_impl.next() + self._GROUP_SND_INCR
self.group_key_map = []
self.group_table = []
self.group_key_sets = []
end
def get_noc() return self.noc end
@@ -175,6 +183,198 @@ class Matter_Fabric : Matter_Expirable
self._counter_group_ctrl_snd_impl.reset(self.counter_group_ctrl_snd)
self.counter_group_data_snd = self._counter_group_data_snd_impl.val()
self.counter_group_ctrl_snd = self._counter_group_ctrl_snd_impl.val()
# Migrate fabrics persisted before group storage was introduced.
if self.group_key_map == nil self.group_key_map = [] end
if self.group_table == nil self.group_table = [] end
if self.group_key_sets == nil self.group_key_sets = [] end
# Berry's JSON encoder serializes int64 values as decimal strings.
# Restore GroupKeySet epoch fields, which are Matter uint64 values.
for key_set : self.group_key_sets
for time_name : ["time0", "time1", "time2"]
var persisted_time = key_set.find(time_name)
if type(persisted_time) == 'string'
var restored_time = self.uint64_from_json(persisted_time)
if restored_time != nil key_set[time_name] = restored_time end
end
end
end
# GroupKeySetID 0 is the operational IPK and is not a valid GroupKeyMap
# target. Remove entries accepted by older builds.
var map_idx = size(self.group_key_map) - 1
while map_idx >= 0
if self.group_key_map[map_idx].find("key_set_id") == 0
self.group_key_map.remove(map_idx)
end
map_idx -= 1
end
end
#############################################################
# Persist this fabric and all associated sessions.
def save()
if self._store != nil
self._store.save_fabrics()
end
end
# Decode the decimal representation emitted by json.dump(int64). Requiring
# an exact round-trip prevents malformed epoch times from becoming zero.
static def uint64_from_json(value)
if isinstance(value, int64) return value end
if type(value) == 'int' return int64.fromu32(value) end
if type(value) != 'string' return nil end
try
var restored = int64.fromstring(value)
return restored.tostring() == value ? restored : nil
except ..
# A damaged or hand-edited epoch value must not abort fabric loading.
return nil
end
end
# Expose GroupID-to-key-set associations for GKM reads and validation.
def get_group_key_map() return self.group_key_map end
# Expose persisted endpoint memberships used by Groups and GKM GroupTable.
def get_group_table() return self.group_table end
# Expose persisted key-set records to the mandatory GKM command handlers.
def get_group_key_sets() return self.group_key_sets end
# Find one fabric-scoped Groups record by GroupID for membership commands.
def find_group(group_id)
for entry : self.group_table
if entry.find("group_id") == group_id
return entry
end
end
return nil
end
# Atomically replace and persist GroupKeyMap after a validated list write.
def replace_group_key_map(entries)
self.group_key_map = entries
self.save()
end
#############################################################
# Group Key Management helpers.
# Locate a stored key set without exposing or copying its epoch key material.
def find_group_key_set(id)
for key_set : self.group_key_sets
if key_set.find("id") == id
return key_set
end
end
return nil
end
# Insert or replace a key set by ID and persist the fabric immediately.
# KeySetWrite calls this only after the Root plugin validates the full set.
def put_group_key_set(key_set)
var id = key_set.find("id")
var idx = 0
while idx < size(self.group_key_sets)
if self.group_key_sets[idx].find("id") == id
self.group_key_sets[idx] = key_set
self.save()
return
end
idx += 1
end
self.group_key_sets.push(key_set)
self.save()
end
# KeySetRemove also removes every GroupKeyMap association referencing the
# key set. Commit both changes with one fabric save.
def remove_group_key_set_and_mappings(id)
var found = false
var idx = 0
while idx < size(self.group_key_sets)
if self.group_key_sets[idx].find("id") == id
self.group_key_sets.remove(idx)
found = true
break
end
idx += 1
end
if !found return false end
idx = size(self.group_key_map) - 1
while idx >= 0
if self.group_key_map[idx].find("key_set_id") == id
self.group_key_map.remove(idx)
end
idx -= 1
end
self.save()
return true
end
#############################################################
# Maintain the fabric's endpoint membership table for the Groups cluster.
# Add an endpoint idempotently, creating the fabric-scoped group if needed.
def add_group_endpoint(group_id, endpoint, name)
var entry
for candidate : self.group_table
if candidate.find("group_id") == group_id
entry = candidate
break
end
end
if entry == nil
entry = {"group_id": group_id, "endpoints": [], "name": name ? name : ""}
self.group_table.push(entry)
elif name != nil
entry["name"] = name
end
var endpoints = entry.find("endpoints")
if endpoints.find(endpoint) == nil
endpoints.push(endpoint)
end
self.save()
end
# Remove one endpoint membership and discard an empty group record. The
# boolean result lets RemoveGroup distinguish success from NOT_FOUND.
def remove_group_endpoint(group_id, endpoint)
var idx = 0
while idx < size(self.group_table)
var entry = self.group_table[idx]
if entry.find("group_id") == group_id
var endpoints = entry.find("endpoints")
var endpoint_idx = endpoints.find(endpoint)
if endpoint_idx == nil return false end
endpoints.remove(endpoint_idx)
if size(endpoints) == 0
self.group_table.remove(idx)
end
self.save()
return true
end
idx += 1
end
return false
end
# Remove an endpoint from every group, as required by RemoveAllGroups, and
# persist once after all memberships have been updated.
def remove_all_groups_for_endpoint(endpoint)
var changed = false
var idx = size(self.group_table) - 1
while idx >= 0
var endpoints = self.group_table[idx].find("endpoints")
var endpoint_idx = endpoints.find(endpoint)
if endpoint_idx != nil
endpoints.remove(endpoint_idx)
changed = true
end
if size(endpoints) == 0
self.group_table.remove(idx)
end
idx -= 1
end
if changed
self.save()
end
end
#############################################################
@@ -789,8 +789,8 @@ class Matter_IM
self.attributedata2raw(raw, ctx, res)
# add suffix 1824FF0C18
raw.add(0x1824FF0C, -4) # add 1824FF0C - InteractionModelRevision 12
# add InteractionModelRevision 13 suffix
raw.add(0x1824FF0D, -4) # InteractionModelRevision 13
raw.add(0x18, 1) # add 18
elif ctx.status != nil
@@ -803,8 +803,8 @@ class Matter_IM
self.attributestatus2raw(raw, ctx, ctx.status)
# add suffix 1824FF0C18
raw.add(0x1824FF0C, -4) # add 1824FF0C - InteractionModelRevision 12
# add InteractionModelRevision 13 suffix
raw.add(0x1824FF0D, -4) # InteractionModelRevision 13
raw.add(0x18, 1) # add 18
else
@@ -1042,8 +1042,8 @@ class Matter_IM
# ignore if content is nil and status is undefined
return false
end
# add suffix 1824FF0C18
raw.add(0x1824FF0C, -4) # add 1824FF0C - InteractionModelRevision 12
# add InteractionModelRevision 13 suffix
raw.add(0x1824FF0D, -4) # InteractionModelRevision 13
raw.add(0x18, 1) # add 18
# log(f"MTR: raw={raw.tohex()}", 3)
@@ -1134,9 +1134,27 @@ class Matter_IM
ret.write_responses = []
var generator = matter.PathGenerator(self.device)
# List updates may be split into an empty whole-list replacement followed
# by one or more ListIndex=NULL appends. Let plugins stage those updates
# for the lifetime of this WriteRequest.
for plugin : self.device.plugins
plugin.begin_write_request(msg)
end
# Track the last operation for each expanded list path in this message.
# The plugin can commit on that operation unless another message follows.
var write_counts = {}
for q : query.write_requests
var count_path = q.path
var count_key = str(count_path.endpoint) + "/" + str(count_path.cluster) + "/" + str(count_path.attribute)
write_counts[count_key] = write_counts.find(count_key, 0) + 1
end
for q:query.write_requests # q is AttributeDataIB
var write_path = q.path
var write_data = q.data
var write_key = str(write_path.endpoint) + "/" + str(write_path.cluster) + "/" + str(write_path.attribute)
write_counts[write_key] = write_counts[write_key] - 1
ctx_log.copy(write_path) # copy endpoint/cluster/attribute in ctx_log for pretty logging
# return an error if the expansion is illegal
@@ -1157,6 +1175,11 @@ class Matter_IM
var ctx
while (ctx := generator.next_attribute())
ctx.msg = msg # enrich with message
ctx.list_index = write_path.list_index
ctx.list_index_present = write_path.list_index_present
ctx.list_index_is_null = write_path.list_index_is_null
ctx.list_write_final = write_counts[write_key] == 0 && !query.more_chunked_messages
ctx.write_tlv = q.data_tlv
if ctx.status != nil # no match, return error because it was direct
ctx.status = nil # remove status to silence output
self.write_single_attribute_status_to_bytes(ret, ctx, write_data)
@@ -1175,6 +1198,10 @@ class Matter_IM
end
for plugin : self.device.plugins
plugin.end_write_request(msg, query.more_chunked_messages)
end
# send the reponse that may need to be chunked if too large to fit in a single UDP message
if !suppress_response
self.send_queue.push(matter.IM_WriteResponse(msg, ret))
@@ -94,7 +94,9 @@ class Matter_AttributePathIB : Matter_IM_base
var endpoint # u16
var cluster # u32
var attribute # u32
var list_index # ?
var list_index # uint16, or nil
var list_index_present # distinguish absent from explicit TLV null
var list_index_is_null
def tostring()
try
@@ -117,7 +119,12 @@ class Matter_AttributePathIB : Matter_IM_base
self.endpoint = val.findsubval(2)
self.cluster = val.findsubval(3)
self.attribute = val.findsubval(4)
self.list_index = val.findsubval(5)
# findsubval() cannot distinguish an absent ListIndex from explicit NULL.
# Preserve both states because Matter uses NULL to mean list append.
var list_index_item = val.findsub(5)
self.list_index_present = list_index_item != nil
self.list_index_is_null = list_index_item != nil && list_index_item.typ == 0x14 #-TLV.NULL-#
self.list_index = self.list_index_is_null ? nil : val.findsubval(5)
return self
end
@@ -129,7 +136,10 @@ class Matter_AttributePathIB : Matter_IM_base
s.add_TLV(2, 0x05 #-TLV.U2-#, self.endpoint)
s.add_TLV(3, 0x06 #-TLV.U4-#, self.cluster)
s.add_TLV(4, 0x06 #-TLV.U4-#, self.attribute)
s.add_TLV(5, 0x05 #-TLV.U2-#, self.list_index)
# Re-emit ListIndex only when it was present, preserving append semantics.
if self.list_index_present
s.add_TLV(5, self.list_index_is_null ? 0x14 #-TLV.NULL-# : 0x05 #-TLV.U2-#, self.list_index)
end
return s
end
end
@@ -196,13 +206,17 @@ class Matter_AttributeDataIB : Matter_IM_base
var data_version # u32
var path # AttributePathIB
var data # any TLV
var data_tlv # original TLV item, needed for list element writes
# decode from TLV
def from_TLV(val)
if val == nil return nil end
self.data_version = val.findsubval(0) # u32
self.path = matter.AttributePathIB().from_TLV(val.findsub(1))
self.data = val.findsubval(2) # any
# Keep the original item so list element deletion (TLV NULL) remains
# distinguishable from an absent or decoded nil value.
self.data_tlv = val.findsub(2)
self.data = self.data_tlv != nil ? self.data_tlv.val : nil # any
return self
end
@@ -566,7 +580,7 @@ class Matter_IM_Message_base : Matter_IM_base
var InteractionModelRevision # 0xFF
def init()
self.InteractionModelRevision = 12 # 12 = Matter 1.4+ Interaction Model revision
self.InteractionModelRevision = 13 # Matter 1.6.1 Interaction Model revision
end
end
@@ -1188,4 +1202,3 @@ assert(r.to_TLV().tlv2raw() == bytes('152400013601153500370024020024033024040018
# 'cluster_status': 0, 'status': 0}>}>}>
-#
@@ -393,7 +393,7 @@ class Matter_IM_ReportData_Pull : Matter_IM_Message
end
# InteractionModelRevision
raw.add(0x24FF, -2) # add 24FF
raw.add(0x0C, 1) # add 0C - InteractionModelRevision 12 (Matter 1.4+)
raw.add(0x0D, 1) # InteractionModelRevision 13
# close struct ReportDataMessage
raw.add(0x18, 1) # add 18
# log(f">>>: {raw.tohex()}", 3)
@@ -30,6 +30,11 @@ class Matter_Path
var endpoint # endpoint or `nil` if expansion
var cluster # cluster or `nil` if expansion
var attribute # attribute or `nil` if expansion
var list_index # list operation index, or nil for whole-list replacement
var list_index_present # true for numeric and explicit-null list operations
var list_index_is_null # true only for append (ListIndex = null)
var list_write_final # final operation for this list in the current WriteRequest
var write_tlv # original AttributeDataIB data TLV
var fabric_filtered # bool or nil
var command # command
var status # status to be returned (0x00 #-matter.SUCCESS-# or matter.<ERROR>)
@@ -48,6 +53,9 @@ class Matter_Path
self.endpoint = c.endpoint
self.cluster = c.cluster
self.attribute = c.attribute
self.list_index = c.list_index
self.list_index_present = c.list_index_present
self.list_index_is_null = c.list_index_is_null
end
# reset the object, allows reuse of the same object
@@ -56,6 +64,11 @@ class Matter_Path
self.endpoint = n
self.cluster = n
self.attribute = n
self.list_index = n
self.list_index_present = n
self.list_index_is_null = n
self.list_write_final = n
self.write_tlv = n
self.fabric_filtered = n
self.command = n
self.status = n
@@ -107,7 +107,7 @@ class Matter_Plugin
# static var TYPES = { <device_type>: <revision> } # needs to be defined for each endpoint
# `FEATURE_MAPS` contains any non-zero value per cluster, if not present default to `0`
static var FEATURE_MAPS = { # feature map per cluster
# 0x0003: 0x00, # Identify: no optional features
0x0004: 0x01, # Groups: GroupNames
0x0006: 0x01, # On/Off: Lighting feature (bit 0)
0x0008: 0x03, # Level Control: On/Off (bit 0) + Lighting (bit 1)
0x0031: 0x05, # Eth + WiFi - the latter is needed for Bluetooth commissioning
@@ -127,8 +127,11 @@ class Matter_Plugin
0x0006: 6, # On/Off - Matter 1.4.1 (OffOnly feature)
0x0008: 6, # Level Control - Matter 1.4.1 (Frequency feature)
0x001D: 2, # Descriptor - Semantic tag list; TagList feature
# 0x001F: 1, # Access Control - Initial Release
0x0028: 3, # Basic Information - Matter 1.4.1 (SpecificationVersion)
# Access Control revision 2 is retained pending confirmation against the
# full 1.6.1 cluster table; the compact spec includes AUX/AuxiliaryACL but
# does not include cluster revision metadata.
0x001F: 2,
0x0028: 4, # Basic Information - Matter 1.6.1
# 0x002A: 1, # OTA Software Update Requestor - Initial Release
# 0x002B: 1, # Localization Configuration - Initial Release
# 0x002C: 1, # Time Format Localization - Initial Release
@@ -142,7 +145,7 @@ class Matter_Plugin
# 0x003B: 1, # Switch - Initial Release
# 0x003C: 1, # Administrator Commissioning - Initial Release
# 0x003E: 1, # Node Operational Credentials - Initial Release
0x003F: 4, # Group Key Management - Matter 1.6.0 (removed GroupKeyMulticastPolicy field in v4)
0x003F: 4, # Group Key Management - Matter 1.6.1 (removed GroupKeyMulticastPolicy field in v4)
# 0x0040: 1, # Fixed Label - Initial Release
# 0x0041: 1, # User Label - Initial Release
# 0x0042: 1, # Boolean State - Initial Release
@@ -205,6 +208,14 @@ class Matter_Plugin
ctx.msg = nil
end
# Start a plugin-scoped write transaction. Most plugins have no writable
# lists, so the base hook is intentionally empty and Root overrides it.
def begin_write_request(msg) end
# Finish or retain a plugin-scoped transaction depending on whether another
# chunk follows. Root overrides this to commit a staged GroupKeyMap.
def end_write_request(msg, more_chunked_messages) end
#############################################################
# parse_configuration
#
@@ -223,8 +223,10 @@ class Matter_Plugin_Device : Matter_Plugin
# ====================================================================================================
elif cluster == 0x0004 # ========== Groups 1.3 p.21 ==========
if attribute == 0x0000 # ---------- ----------
return nil # TODO
if attribute == 0x0000 # ---------- NameSupport ----------
return tlv_solo.set(0x04 #-TLV.U1-#, self.device.GROUP_TRANSPORT_READY ? 0x80 : 0)
elif attribute == 0xFFFC # ---------- FeatureMap ----------
return tlv_solo.set(0x06 #-TLV.U4-#, self.device.GROUP_TRANSPORT_READY ? 1 : 0)
end
# ====================================================================================================
@@ -335,8 +337,123 @@ class Matter_Plugin_Device : Matter_Plugin
end
# ====================================================================================================
elif cluster == 0x0004 # ========== Groups 1.3 p.21 ==========
# TODO
return true
# Persist membership management only when encrypted multicast reception
# is available. Until then commands fail closed instead of advertising a
# group configuration whose messages would be silently discarded.
var fabric = session.get_fabric()
if fabric == nil
ctx.status = 0x7E #-matter.UNSUPPORTED_ACCESS-#
return nil
end
if command == 0x0000 # ---------- AddGroup ----------
var group_id = val.findsubval(0)
var group_name = val.findsubval(1, "")
var status = 0
if group_id == nil
ctx.status = 0x85 #-matter.INVALID_COMMAND-#
return nil
elif !self.device.GROUP_TRANSPORT_READY
status = 0x01 #-matter.FAILURE-#
elif type(group_name) != 'string' || size(group_name) > 16
status = 0x87 #-matter.CONSTRAINT_ERROR-#
elif group_id == nil || group_id < 1 || group_id > 0xFEFF
status = 0x87 #-matter.CONSTRAINT_ERROR-#
elif fabric.find_group(group_id) == nil && size(fabric.get_group_table()) >= 4
status = 0x89 #-matter.RESOURCE_EXHAUSTED-#
else
fabric.add_group_endpoint(group_id, self.endpoint, group_name)
end
var response = TLV.Matter_TLV_struct()
response.add_TLV(0, 0x04 #-TLV.U1-#, status)
response.add_TLV(1, 0x05 #-TLV.U2-#, group_id)
ctx.command = 0x0000 # AddGroupResponse
return response
elif command == 0x0001 # ---------- ViewGroup ----------
var group_id = val.findsubval(0)
if group_id == nil
ctx.status = 0x85 #-matter.INVALID_COMMAND-#
return nil
end
var group = fabric.find_group(group_id)
var member = self.device.GROUP_TRANSPORT_READY &&
group != nil && group.find("endpoints", []).find(self.endpoint) != nil
var response = TLV.Matter_TLV_struct()
response.add_TLV(0, 0x04 #-TLV.U1-#,
group_id == 0 ? 0x87 #-matter.CONSTRAINT_ERROR-# :
member ? 0 : 0x8B #-matter.NOT_FOUND-#)
response.add_TLV(1, 0x05 #-TLV.U2-#, group_id)
response.add_TLV(2, 0x0C #-TLV.UTF1-#, member ? group.find("name", "") : "")
ctx.command = 0x0001 # ViewGroupResponse
return response
elif command == 0x0002 # ---------- GetGroupMembership ----------
var requested = val.findsub(0)
var response = TLV.Matter_TLV_struct()
response.add_TLV(0, 0x04 #-TLV.U1-#,
self.device.GROUP_TRANSPORT_READY ? 4 - size(fabric.get_group_table()) : 0)
var groups = response.add_array(1)
if self.device.GROUP_TRANSPORT_READY
for group : fabric.get_group_table()
if group.find("endpoints", []).find(self.endpoint) == nil
continue
end
var include = requested == nil || size(requested.val) == 0
if !include
for requested_id : requested.val
if requested_id.val == group.find("group_id")
include = true
break
end
end
end
if include
groups.add_TLV(nil, 0x05 #-TLV.U2-#, group.find("group_id"))
end
end
end
ctx.command = 0x0002 # GetGroupMembershipResponse
return response
elif command == 0x0003 # ---------- RemoveGroup ----------
var group_id = val.findsubval(0)
if group_id == nil
ctx.status = 0x85 #-matter.INVALID_COMMAND-#
return nil
end
var removed = group_id != 0 && fabric.remove_group_endpoint(group_id, self.endpoint)
var response = TLV.Matter_TLV_struct()
response.add_TLV(0, 0x04 #-TLV.U1-#,
group_id == 0 ? 0x87 #-matter.CONSTRAINT_ERROR-# :
removed ? 0 : 0x8B #-matter.NOT_FOUND-#)
response.add_TLV(1, 0x05 #-TLV.U2-#, group_id)
ctx.command = 0x0003 # RemoveGroupResponse
return response
elif command == 0x0004 # ---------- RemoveAllGroups ----------
fabric.remove_all_groups_for_endpoint(self.endpoint)
return true
elif command == 0x0005 # ---------- AddGroupIfIdentifying ----------
var group_id = val.findsubval(0)
if group_id == nil
ctx.status = 0x85 #-matter.INVALID_COMMAND-#
return nil
elif group_id == 0
ctx.status = 0x87 #-matter.CONSTRAINT_ERROR-#
return nil
end
var group_name = val.findsubval(1, "")
if type(group_name) != 'string' || size(group_name) > 16
ctx.status = 0x87 #-matter.CONSTRAINT_ERROR-#
return nil
end
# IdentifyTime is always zero in the base device implementation, so
# the mandatory command succeeds without changing group membership.
# This remains valid while multicast group transport is disabled.
return true
end
# ====================================================================================================
elif cluster == 0x0005 # ========== Scenes 1.4 p.30 ==========
@@ -18,10 +18,10 @@
#
#################################################################################
# Matter 1.6.0 Root Node Device Specification
# Matter 1.6.1 Root Node Device Specification
#################################################################################
# Device Type: Root Node (0x0016)
# Device Type Revision: 3 (Matter 1.6.0, DataModelRevision = 20)
# Device Type Revision: 5 (Matter 1.6.1, DataModelRevision = 21)
# Class: Simple | Scope: Node
#
# The Root Node device type represents the base functionality required by all
@@ -78,7 +78,7 @@ class Matter_Plugin_Root : Matter_Plugin
# ATTRIBUTES:
# ID | Name | Type | Constraint | Quality | Default | Access | Conf
# -------|-------------------------|---------|------------|---------|---------|--------|-----
# 0x0000 | DataModelRevision | uint16 | all | F | 20 | R V | M
# 0x0000 | DataModelRevision | uint16 | all | F | 21 | R V | M
# 0x0001 | VendorName | string | max 32 | F | - | R V | M
# 0x0002 | VendorID | uint16 | all | F | - | R V | M
# 0x0003 | ProductName | string | max 32 | F | - | R V | M
@@ -104,7 +104,7 @@ class Matter_Plugin_Root : Matter_Plugin
# 0x00 | StartUp | CRITICAL | M
#
# NOTES:
# - DataModelRevision: 20 = Matter 1.6.0
# - DataModelRevision: 21 = Matter 1.6.1
# - VendorID: 0xFFF1-0xFFF4 for test vendors
# - NodeLabel: User-friendly name, writable
# - Location: ISO 3166-1 alpha-2 country code
@@ -339,21 +339,24 @@ class Matter_Plugin_Root : Matter_Plugin
#################################################################################
#################################################################################
# Matter 1.4.1 Access Control Cluster (0x001F)
# Matter 1.6.1 Access Control Cluster (0x001F) - Tasmota fixed ACL profile
#################################################################################
# Cluster Revision: 2 (Matter 1.4.1)
# Cluster Revision: 2
# Role: Utility | Scope: Node
#
# Manages Access Control Lists (ACLs) for authorization.
# Tasmota exposes one immutable wildcard ACL entry instead of implementing the
# full writable ACL authorization engine. This keeps code and persistent state
# small, but deliberately gives every authenticated CASE peer on a fabric
# Administer access to every endpoint and cluster.
#
# ATTRIBUTES:
# ID | Name | Type | Constraint | Quality | Default | Access | Conf
# -------|-----------------------------------|--------------|------------|---------|---------|--------|-----
# 0x0000 | ACL | list[struct] | max 4 | NF | [] | RW FA | M
# 0x0000 | ACL | list[struct] | fixed 1 | NF | wildcard| R FA | M
# 0x0001 | Extension | list[struct] | max 4 | NF | [] | RW FA | O
# 0x0002 | SubjectsPerAccessControlEntry | uint16 | min 4 | F | 4 | R V | M
# 0x0003 | TargetsPerAccessControlEntry | uint16 | min 3 | F | 3 | R V | M
# 0x0004 | AccessControlEntriesPerFabric | uint16 | min 4 | F | 4 | R V | M
# 0x0004 | AccessControlEntriesPerFabric | uint16 | fixed 1 | F | 1 | R V | M
#
# AccessControlEntryStruct:
# - Privilege (enum8): 1=View, 2=ProxyView, 3=Operate, 4=Manage, 5=Administer
@@ -366,11 +369,15 @@ class Matter_Plugin_Root : Matter_Plugin
# Administer > Manage > Operate > ProxyView, View
#
# NOTES:
# - ACL: Per-fabric access control entries
# - Subjects: null = wildcard (all subjects)
# - Targets: null = wildcard (all targets)
# - Fixed entry: Privilege=Administer, AuthMode=CASE, Subjects=null,
# Targets=null, FabricIndex=current accessing fabric.
# - ACL writes are acknowledged and discarded for commissioner compatibility;
# no ACL data is persisted and reads always return the fixed entry.
# - CATs, Node-ID restrictions, target restrictions, Group AuthMode, and
# AccessControlEntryChanged events are not implemented.
# - This is not fully conformant with Matter's writable ACL and minimum-entry
# requirements. It is an explicit resource/security tradeoff for Tasmota.
# - PASE commissioning implicitly grants Administer privilege
# - Minimum 4 ACL entries per fabric
#################################################################################
#################################################################################
@@ -395,7 +402,7 @@ class Matter_Plugin_Root : Matter_Plugin
# - FabricIndex (uint8): Fabric identifier
#
# GroupKeySetStruct:
# - GroupKeySetID (uint16): Key set identifier (0 = IPK)
# - GroupKeySetID (uint16): key set identifier; 0 is not valid in GroupKeyMap
# - GroupKeySecurityPolicy (enum8): Security policy
# - EpochKey0/1/2 (octstr, nullable): Epoch keys
# - EpochStartTime0/1/2 (uint64, nullable): Epoch start times
@@ -598,7 +605,7 @@ class Matter_Plugin_Root : Matter_Plugin
static var CLUSTERS = matter.consolidate_clusters(_class, {
# 0x001D: inherited # Descriptor Cluster 9.5 p.453
0x001F: [0,2,3,4], # Access Control Cluster, p.461
0x0028: [0,1,2,3,4,5,6,7,8,9,0x0A,0x0F,0x11,0x12,0x13,0x15,0x16],# Basic Information Cluster cluster 11.1 p.565
0x0028: [0,1,2,3,4,5,6,7,8,9,0x0A,0x0F,0x11,0x12,0x13,0x15,0x16,0x18],# Basic Information Cluster
# 0x002A: [0,1,2,3], # OTA Software Update Requestor Cluster Definition 11.19.7 p.762
0x002B: [0,1], # Localization Configuration Cluster 11.3 p.580
0x002C: [0,1,2], # Time Format Localization Cluster 11.4 p.581
@@ -611,7 +618,7 @@ class Matter_Plugin_Root : Matter_Plugin
0x0038: [0,1,7], # Time Synchronization 11.16 p.689
0x003C: [0,1,2], # Administrator Commissioning Cluster 11.18 p.725
0x003E: [0,1,2,3,4,5], # Node Operational Credentials Cluster 11.17 p.704
0x003F: [0], # Group Key Management Cluster 11.2 p.572
0x003F: [0,1,2,3], # Group Key Management Cluster
# ICD Management Cluster (0x0046) - Section 9.17
# For SIT (Short Idle Time) devices without CIP/LITS features:
# - IdleModeDuration (0x0000): mandatory
@@ -621,7 +628,12 @@ class Matter_Plugin_Root : Matter_Plugin
# Note: OperatingMode(0x0008) requires LITS feature
0x0046: [0,1,2] # ICD Management Cluster - base SIT mode (no CIP/LITS features)
})
static var TYPES = { 0x0016: 3 } # Root node - Matter 1.4.1 Device Library Rev 3
static var TYPES = { 0x0016: 5 } # Root node - Matter 1.6.1 Device Library Rev 5
# static var MATTER_EPOCH_OFFSET = 946684800 # seconds from Unix epoch to 2000-01-01
var _group_key_map_write_staged # request-scoped candidate GroupKeyMap
var _group_key_map_write_failed # prevents partial GroupKeyMap persistence
var _write_exchange_id # identifies a multi-message list transaction
var _write_session # prevents transaction reuse by another CASE peer
#############################################################
# Constructor
@@ -632,6 +644,277 @@ class Matter_Plugin_Root : Matter_Plugin
self.publish_event(0x0033, 0x03, 2 #-matter.EVENT_CRITICAL-#, matter.TLV.Matter_TLV_item().set(0x04 #-matter.TLV.U1-#, 1)) # Event BootReason - PowerOnReboot - TODO if we need to refine
end
# Open or resume the GroupKeyMap list-write transaction for this exchange.
# The transaction survives MoreChunkedMessages.
def begin_write_request(msg)
var exchange_id = msg != nil ? msg.exchange_id : nil
var session = msg != nil ? msg.session : nil
if exchange_id != nil && self._write_exchange_id == exchange_id &&
self._write_session == session
return
end
self._group_key_map_write_staged = nil
self._group_key_map_write_failed = false
self._write_exchange_id = exchange_id
self._write_session = session
end
# Close a WriteRequest transaction. On the final message this commits staged
# lists whose last operation appeared in an earlier protocol message.
def end_write_request(msg, more_chunked_messages)
if more_chunked_messages return end
# The final message may not repeat the GroupKeyMap path. Commit any valid
# pending map here so earlier successful chunks are not silently lost.
var fabric = (msg != nil && msg.session != nil) ? msg.session.get_fabric() : nil
if fabric != nil
if self._group_key_map_write_staged != nil && !self._group_key_map_write_failed
fabric.replace_group_key_map(self._group_key_map_write_staged)
self.attribute_updated(0x003F, 0x0000)
end
end
# Failed staged maps are discarded; partial lists are never persisted.
self._group_key_map_write_staged = nil
self._write_exchange_id = nil
self._write_session = nil
end
# Return Tasmota's fixed Access Control policy. Every authenticated CASE peer
# on the accessing fabric receives Administer privilege for all targets.
# This deliberately trades Matter ACL mutability and least-privilege control
# for a small implementation suitable for resource-constrained firmware.
def fixed_acl_to_tlv(fabric)
var TLV = matter.TLV
var result = TLV.Matter_TLV_array()
if fabric != nil
var out = result.add_struct(nil)
out.add_TLV(1, 0x04 #-TLV.U1-#, 5) # Administer
out.add_TLV(2, 0x04 #-TLV.U1-#, 2) # CASE
out.add_TLV(3, 0x14 #-TLV.NULL-#, nil) # all CASE subjects
out.add_TLV(4, 0x14 #-TLV.NULL-#, nil) # all targets
out.add_TLV(0xFE, 0x04 #-TLV.U1-#, fabric.get_fabric_index())
end
return result
end
#############################################################
# Encode and validate Group Key Management fabric data.
# Build the fabric-filtered GroupKeyMap attribute from normalized storage.
def group_key_map_to_tlv(fabric)
var TLV = matter.TLV
var result = TLV.Matter_TLV_array()
if fabric == nil
return result
end
for entry : fabric.get_group_key_map()
var out = result.add_struct(nil)
out.add_TLV(1, 0x05 #-TLV.U2-#, entry.find("group_id"))
out.add_TLV(2, 0x05 #-TLV.U2-#, entry.find("key_set_id"))
out.add_TLV(0xFE, 0x04 #-TLV.U1-#, fabric.get_fabric_index())
end
return result
end
# Validate a complete or single-entry GroupKeyMap value and convert it to
# JSON-safe storage. References must resolve to existing non-reserved sets.
def normalize_group_key_map(write_data, fabric)
if !isinstance(write_data, list) || size(write_data) > 4
return nil
end
var normalized = []
var group_ids = []
for encoded : write_data
var group_id = encoded.findsubval(1)
var key_set_id = encoded.findsubval(2)
var fabric_index = encoded.findsubval(0xFE)
if group_id == nil || group_id < 1 || group_id > 0xFEFF ||
key_set_id == nil || key_set_id < 1 || key_set_id > 0xFFFF ||
fabric.find_group_key_set(key_set_id) == nil ||
group_ids.find(group_id) != nil ||
(fabric_index != nil && fabric_index != 0 && fabric_index != fabric.get_fabric_index())
return nil
end
group_ids.push(group_id)
normalized.push({"group_id": group_id, "key_set_id": key_set_id})
end
return normalized
end
# Apply whole-list, append, replace, or delete semantics to a staged
# GroupKeyMap so chunked writes are committed atomically.
def write_group_key_map_operation(fabric, ctx, write_data)
if self._group_key_map_write_failed
ctx.status = 0x80 #-matter.INVALID_ACTION-#
return false
end
var staged = self._group_key_map_write_staged
if staged == nil staged = fabric.get_group_key_map().copy() end
var replacement
if !ctx.list_index_present
replacement = self.normalize_group_key_map(write_data, fabric)
if replacement == nil
self._group_key_map_write_failed = true
ctx.status = 0x87 #-matter.CONSTRAINT_ERROR-#
return false
end
staged = replacement
elif ctx.list_index_is_null
replacement = self.normalize_group_key_map([ctx.write_tlv], fabric)
if replacement == nil || size(staged) >= 4
self._group_key_map_write_failed = true
ctx.status = 0x87 #-matter.CONSTRAINT_ERROR-#
return false
end
var group_id = replacement[0].find("group_id")
for existing : staged
if existing.find("group_id") == group_id
self._group_key_map_write_failed = true
ctx.status = 0x87 #-matter.CONSTRAINT_ERROR-#
return false
end
end
staged.push(replacement[0])
else
var index = ctx.list_index
if index == nil || index < 0 || index >= size(staged)
self._group_key_map_write_failed = true
ctx.status = 0x87 #-matter.CONSTRAINT_ERROR-#
return false
end
if ctx.write_tlv != nil && ctx.write_tlv.typ == 0x14 #-TLV.NULL-#
staged.remove(index)
else
replacement = self.normalize_group_key_map([ctx.write_tlv], fabric)
if replacement == nil
self._group_key_map_write_failed = true
ctx.status = 0x87 #-matter.CONSTRAINT_ERROR-#
return false
end
var replacement_group_id = replacement[0].find("group_id")
var duplicate_index = 0
while duplicate_index < size(staged)
if duplicate_index != index && staged[duplicate_index].find("group_id") == replacement_group_id
self._group_key_map_write_failed = true
ctx.status = 0x87 #-matter.CONSTRAINT_ERROR-#
return false
end
duplicate_index += 1
end
staged[index] = replacement[0]
end
end
self._group_key_map_write_staged = staged
if ctx.list_write_final
fabric.replace_group_key_map(staged)
self._group_key_map_write_staged = nil
self.attribute_updated(ctx.cluster, ctx.attribute)
end
return true
end
# Encode the persisted Groups membership table for the GKM GroupTable
# attribute. Only records belonging to the accessing fabric are supplied.
def group_table_to_tlv(fabric)
var TLV = matter.TLV
var result = TLV.Matter_TLV_array()
if fabric == nil
return result
end
for entry : fabric.get_group_table()
var out = result.add_struct(nil)
out.add_TLV(1, 0x05 #-TLV.U2-#, entry.find("group_id"))
var endpoints = out.add_array(2)
for endpoint : entry.find("endpoints", [])
endpoints.add_TLV(nil, 0x05 #-TLV.U2-#, endpoint)
end
out.add_TLV(3, 0x0C #-TLV.UTF1-#, entry.find("name", ""))
out.add_TLV(0xFE, 0x04 #-TLV.U1-#, fabric.get_fabric_index())
end
return result
end
# Validate KeySetWrite's GroupKeySetStruct and normalize epoch keys/times for
# persistence. int64 conversion keeps minimally encoded uint timestamps safe.
def normalize_group_key_set(encoded)
if encoded == nil
return nil
end
var id = encoded.findsubval(0)
var policy = encoded.findsubval(1)
if id == nil || id < 1 || id > 0xFFFF ||
policy == nil || policy < 0 || policy > 1
return nil
end
var result = {"id": id, "policy": policy}
var key_tags = [2,4,6]
var time_tags = [3,5,7]
var previous_time
var idx = 0
while idx < 3
var key_item = encoded.findsub(key_tags[idx])
var time_item = encoded.findsub(time_tags[idx])
var key = (key_item != nil && key_item.typ != 0x14) ? key_item.val : nil
var start_time = (time_item != nil && time_item.typ != 0x14) ? time_item.val : nil
if (key == nil) != (start_time == nil)
return nil
end
if key != nil
if !isinstance(key, bytes) || size(key) != 16
return nil
end
if type(start_time) == 'int'
# TLV decodes minimally encoded uint values as native ints. Convert
# those as unsigned values before checking or comparing them.
start_time = int64.fromu32(start_time)
elif isinstance(start_time, int64)
if start_time < 0 return nil end
else
return nil
end
# EpochKey0 is mandatory. Later epochs must be contiguous and their
# start times strictly increase.
if idx > 0 && result.find("key" + str(idx - 1)) == nil
return nil
end
if idx == 0 && start_time.low32() == 0 && start_time.high32() == 0
return nil
end
if previous_time != nil && start_time <= previous_time
return nil
end
result["key" + str(idx)] = key.tohex()
result["time" + str(idx)] = start_time
previous_time = start_time
end
idx += 1
end
if result.find("key0") == nil
return nil
end
return result
end
# Build a KeySetRead response. Epoch start times are visible, but symmetric
# epoch keys are deliberately replaced with NULL and never leave the device.
def group_key_set_to_tlv(key_set, id)
var TLV = matter.TLV
var out = TLV.Matter_TLV_struct()
out.add_TLV(0, 0x05 #-TLV.U2-#, id)
out.add_TLV(1, 0x04 #-TLV.U1-#, key_set != nil ? key_set.find("policy", 0) : 0)
# KeySetRead never discloses group key material; only epoch start times are returned.
var idx = 0
while idx < 3
out.add_TLV(2 + idx * 2, 0x14 #-TLV.NULL-#, nil)
var start_time = key_set != nil ? key_set.find("time" + str(idx)) : nil
out.add_TLV(3 + idx * 2, start_time == nil ? 0x14 #-TLV.NULL-# : 0x07 #-TLV.U8-#, start_time)
idx += 1
end
return out
end
#############################################################
# read an attribute
#
@@ -717,14 +1000,24 @@ class Matter_Plugin_Root : Matter_Plugin
# ====================================================================================================
elif cluster == 0x0038 # ========== Time Synchronization 11.16 p.689 ==========
if attribute == 0x0000 # ---------- UTCTime / epoch_us ----------
var epoch_us = int64(tasmota.rtc_utc()) * int64(1000000)
return tlv_solo.set(0x07 #-TLV.U8-#, epoch_us) # TODO test the conversion of int64()
var unix_time = tasmota.rtc_utc()
if unix_time < 946684800 #-self.MATTER_EPOCH_OFFSET-#
return tlv_solo.set(0x14 #-TLV.NULL-#, nil) # RTC is not valid yet
end
# Matter epoch-us starts at 2000-01-01, not at the Unix epoch.
var epoch_us = (int64(unix_time) - int64(946684800 #-self.MATTER_EPOCH_OFFSET-#)) * int64(1000000)
return tlv_solo.set(0x07 #-TLV.U8-#, epoch_us)
elif attribute == 0x0001 # ---------- Granularity / enum ----------
return tlv_solo.set(0x04 #-TLV.U1-#, 3) # MillisecondsGranularity (NTP every hour, i.e. 36ms max drift)
# TODO add some missing args
elif attribute == 0x0007 # ---------- LocalTime / epoch_us ----------
var epoch_us = int64(tasmota.rtc('local')) * int64(1000000)
return tlv_solo.set(0x07 #-TLV.U8-#, epoch_us) # TODO test the conversion of int64()
var unix_time = tasmota.rtc('local')
if unix_time < 946684800 #-self.MATTER_EPOCH_OFFSET-#
return tlv_solo.set(0x14 #-TLV.NULL-#, nil) # RTC is not valid yet
end
# LocalTime uses the same Matter epoch, with the local offset applied.
var epoch_us = (int64(unix_time) - int64(946684800 #-self.MATTER_EPOCH_OFFSET-#)) * int64(1000000)
return tlv_solo.set(0x07 #-TLV.U8-#, epoch_us)
end
# ====================================================================================================
@@ -796,7 +1089,7 @@ class Matter_Plugin_Root : Matter_Plugin
self.ack_request(ctx) # long operation, send Ack first
if attribute == 0x0000 # ---------- DataModelRevision ----------
return tlv_solo.set(0x05 #-TLV.U2-#, 20) # 20 = Matter 1.6.0 (revision 20 of the Data Model)
return tlv_solo.set(0x05 #-TLV.U2-#, 21) # Matter 1.6.1 data model
elif attribute == 0x0001 # ---------- VendorName / string ----------
return tlv_solo.set(0x0C #-TLV.UTF1-#, "Tasmota")
elif attribute == 0x0002 # ---------- VendorID / vendor-id ----------
@@ -829,37 +1122,60 @@ class Matter_Plugin_Root : Matter_Plugin
elif attribute == 0x0013 # ---------- CapabilityMinima / CapabilityMinimaStruct ----------
var cps = TLV.Matter_TLV_struct()
cps.add_TLV(0, 0x05 #-TLV.U2-#, 3) # CaseSessionsPerFabric = 3
cps.add_TLV(1, 0x05 #-TLV.U2-#, 3) # SubscriptionsPerFabric = 5
cps.add_TLV(1, 0x05 #-TLV.U2-#, 3) # SubscriptionsPerFabric = 3
cps.add_TLV(2, 0x05 #-TLV.U2-#, 1) # SimultaneousInvocationsSupported
cps.add_TLV(3, 0x05 #-TLV.U2-#, 1) # SimultaneousWritesSupported
cps.add_TLV(4, 0x05 #-TLV.U2-#, 9) # ReadPathsSupported
cps.add_TLV(5, 0x05 #-TLV.U2-#, 3) # SubscribePathsSupported
return cps
elif attribute == 0x0015 # ---------- SpecificationVersion / uint32 ----------
return tlv_solo.set(0x06 #-TLV.U4-#, 0x01040100) # Matter 1.4.1.0
return tlv_solo.set(0x06 #-TLV.U4-#, 0x01060100) # Matter 1.6.1.0
elif attribute == 0x0016 # ---------- MaxPathsPerInvoke / uint16 ----------
return tlv_solo.set(0x05 #-TLV.U2-#, 1)
elif attribute == 0x0018 # ---------- ConfigurationVersion / uint32 ----------
return tlv_solo.set(0x06 #-TLV.U4-#, self.device.configuration_version)
end
# ====================================================================================================
elif cluster == 0x001F # ========== Access Control Cluster 9.10 p.461 ==========
if attribute == 0x0002 # ---------- SubjectsPerAccessControlEntry / uint16 ----------
if attribute == 0x0000 # ---------- ACL / list[AccessControlEntryStruct] ----------
return self.fixed_acl_to_tlv(session.get_fabric())
elif attribute == 0x0002 # ---------- SubjectsPerAccessControlEntry / uint16 ----------
return tlv_solo.set(0x05 #-TLV.U2-#, 4) # spec minimum
elif attribute == 0x0003 # ---------- TargetsPerAccessControlEntry / uint16 ----------
return tlv_solo.set(0x05 #-TLV.U2-#, 3) # spec minimum
elif attribute == 0x0004 # ---------- AccessControlEntriesPerFabric / uint16 ----------
return tlv_solo.set(0x05 #-TLV.U2-#, 4) # spec minimum
return tlv_solo.set(0x05 #-TLV.U2-#, 1) # one fixed wildcard entry
end
# ====================================================================================================
elif cluster == 0x003F # ========== Group Key Management Cluster 11.2 p.572 ==========
if attribute == 0x0000 # ---------- GroupKeyMap / list[GroupKeyMapStruct] ----------
# Return empty list for now - group keys managed internally
return TLV.Matter_TLV_array()
if !self.device.GROUP_TRANSPORT_READY return TLV.Matter_TLV_array() end
return self.group_key_map_to_tlv(session.get_fabric())
elif attribute == 0x0001 # ---------- GroupTable / list[GroupInfo] ----------
return TLV.Matter_TLV_array() # empty list for now
if !self.device.GROUP_TRANSPORT_READY return TLV.Matter_TLV_array() end
return self.group_table_to_tlv(session.get_fabric())
elif attribute == 0x0002 # ---------- MaxGroupsPerFabric / uint16 ----------
return tlv_solo.set(0x05 #-TLV.U2-#, 4) # spec minimum
# Groups remains discoverable for unicast compatibility, but group
# provisioning is fail-closed until encrypted multicast receive exists.
return tlv_solo.set(0x05 #-TLV.U2-#, self.device.GROUP_TRANSPORT_READY ? 4 : 0)
elif attribute == 0x0003 # ---------- MaxGroupKeysPerFabric / uint16 ----------
return tlv_solo.set(0x05 #-TLV.U2-#, 3) # spec minimum
elif attribute == 0xFFF8 # ---------- GeneratedCommandList ----------
var generated = TLV.Matter_TLV_array()
generated.add_TLV(nil, 0x06 #-TLV.U4-#, 0x0002) # KeySetReadResponse
generated.add_TLV(nil, 0x06 #-TLV.U4-#, 0x0005) # KeySetReadAllIndicesResponse
return generated
elif attribute == 0xFFF9 # ---------- AcceptedCommandList ----------
var accepted = TLV.Matter_TLV_array()
accepted.add_TLV(nil, 0x06 #-TLV.U4-#, 0x0000) # KeySetWrite
accepted.add_TLV(nil, 0x06 #-TLV.U4-#, 0x0001) # KeySetRead
accepted.add_TLV(nil, 0x06 #-TLV.U4-#, 0x0003) # KeySetRemove
accepted.add_TLV(nil, 0x06 #-TLV.U4-#, 0x0004) # KeySetReadAllIndices
return accepted
end
# ====================================================================================================
@@ -1278,6 +1594,80 @@ class Matter_Plugin_Root : Matter_Plugin
return true
end
# ====================================================================================================
elif cluster == 0x003F # ========== Group Key Management Cluster ==========
# These commands implement the persistent management plane only. Group
# message decryption and multicast dispatch remain gated separately.
var fabric = session.get_fabric()
if fabric == nil
ctx.status = 0x7E #-matter.UNSUPPORTED_ACCESS-#
return nil
end
if command == 0x0000 # ---------- KeySetWrite ----------
var encoded_key_set = val.findsub(0)
if encoded_key_set == nil || encoded_key_set.findsubval(0) == 0
ctx.status = 0x85 #-matter.INVALID_COMMAND-#
return nil
end
if encoded_key_set.findsubval(1) == 1 &&
(self.FEATURE_MAPS.find(0x003F, 0) & 0x01) == 0
ctx.status = 0x87 #-matter.CONSTRAINT_ERROR-#
return nil
end
var key_set
try
key_set = self.normalize_group_key_set(encoded_key_set)
except .. as e, m
log("MTR: invalid KeySetWrite: " + str(e) + "|" + str(m), 2)
end
if key_set == nil
ctx.status = 0x85 #-matter.INVALID_COMMAND-#
return nil
end
if fabric.find_group_key_set(key_set.find("id")) == nil &&
size(fabric.get_group_key_sets()) >= 2 # key set 0 (IPK) is the third supported set
ctx.status = 0x89 #-matter.RESOURCE_EXHAUSTED-#
return nil
end
fabric.put_group_key_set(key_set)
return true
elif command == 0x0001 # ---------- KeySetRead ----------
var id = val.findsubval(0)
var key_set = (id == 0) ? nil : fabric.find_group_key_set(id)
if id == nil || (id != 0 && key_set == nil)
ctx.status = 0x8B #-matter.NOT_FOUND-#
return nil
end
var response = TLV.Matter_TLV_struct()
response.add_obj(0, self.group_key_set_to_tlv(key_set, id))
ctx.command = 0x0002 # KeySetReadResponse
return response
elif command == 0x0003 # ---------- KeySetRemove ----------
var id = val.findsubval(0)
if id == nil || id == 0
ctx.status = 0x85 #-matter.INVALID_COMMAND-#
return nil
end
if !fabric.remove_group_key_set_and_mappings(id)
ctx.status = 0x8B #-matter.NOT_FOUND-#
return nil
end
return true
elif command == 0x0004 # ---------- KeySetReadAllIndices ----------
var response = TLV.Matter_TLV_struct()
var indices = response.add_array(0)
indices.add_TLV(nil, 0x05 #-TLV.U2-#, 0) # operational IPK key set
for key_set : fabric.get_group_key_sets()
indices.add_TLV(nil, 0x05 #-TLV.U2-#, key_set.find("id"))
end
ctx.command = 0x0005 # KeySetReadAllIndicesResponse
return response
end
# ====================================================================================================
elif cluster == 0x002A # ========== OTA Software Update Requestor Cluster Definition 11.19.7 p.762 ==========
@@ -1349,9 +1739,37 @@ class Matter_Plugin_Root : Matter_Plugin
# ====================================================================================================
elif cluster == 0x001F # ========== Access Control Cluster 9.10 p.461 ==========
if attribute == 0x0000 # ACL - list[AccessControlEntryStruct]
# Apple Home writes its commissioned administrator ACL immediately
# after CommissioningComplete and removes the fabric if that write is
# rejected. Tasmota deliberately keeps a fixed wildcard CASE/Administer
# policy, so acknowledge this compatibility write but do not persist or
# apply it. Subsequent reads continue to expose fixed_acl_to_tlv().
return true
end
# ====================================================================================================
elif cluster == 0x003F # ========== Group Key Management Cluster ==========
if attribute == 0x0000 # GroupKeyMap - list[GroupKeyMapStruct]
# Do not acknowledge usable group mappings until the encrypted
# multicast receive path can consume them.
if !self.device.GROUP_TRANSPORT_READY
ctx.status = 0x88 #-matter.UNSUPPORTED_WRITE-#
return false
end
var fabric = session.get_fabric()
if fabric == nil
ctx.status = 0x7E #-matter.UNSUPPORTED_ACCESS-#
return false
end
try
return self.write_group_key_map_operation(fabric, ctx, write_data)
except .. as e, m
log("MTR: invalid GroupKeyMap write: " + str(e) + "|" + str(m), 2)
ctx.status = 0x8D #-matter.INVALID_DATA_TYPE-#
return false
end
end
# ====================================================================================================
elif cluster == 0x0028 # ========== Basic Information Cluster cluster 11.1 p.565 ==========
@@ -22,6 +22,10 @@ import matter
#@ solidify:Matter_Device,weak
class Matter_Device
# Group key and membership storage exists, but encrypted multicast receive
# and dispatch are not implemented yet. Keep provisioning fail-closed until
# the group data plane is operational.
static var GROUP_TRANSPORT_READY = false
static var UDP_PORT = 5540 # this is the default port for group multicast, we also use it for unicast
static var VENDOR_ID = 0xFFF1
static var PRODUCT_ID = 0x8000
@@ -58,6 +62,7 @@ class Matter_Device
var disable_bridge_mode # default is bridge mode, this flag disables this mode for some non-compliant controllers
var next_ep # next endpoint to be allocated for bridge, start at 1
var debug # debug mode, output all values when responding to read request with wildcard
var configuration_version # persistent Basic Information configuration version
# cron equivalent to call `read_sensors()` regularly and dispatch to all entpoints
var probe_sensor_time # number of milliseconds to wait between each `read_sensors()` or `nil` if none active
var probe_sensor_timestamp # timestamp for `read_sensors()` probe (in millis())
@@ -81,6 +86,7 @@ class Matter_Device
self.discovered_sensors = {}
self.discovery_subscribed = false
self.next_ep = self.EP # start at endpoint 2 for dynamically allocated endpoints (1 reserved for aggregator)
self.configuration_version = 1
self.ipv4only = false
self.disable_bridge_mode = false
self.commissioning = matter.Commissioning(self)
@@ -464,7 +470,7 @@ class Matter_Device
import json
self.update_remotes_info() # update self.plugins_config_remotes
var j = format('{"distinguish":%i,"passcode":%i,"ipv4only":%s,"disable_bridge_mode":%s,"nextep":%i', self.root_discriminator, self.root_passcode, self.ipv4only ? 'true':'false', self.disable_bridge_mode ? 'true':'false', self.next_ep)
var j = format('{"distinguish":%i,"passcode":%i,"ipv4only":%s,"disable_bridge_mode":%s,"nextep":%i,"configuration_version":%i', self.root_discriminator, self.root_passcode, self.ipv4only ? 'true':'false', self.disable_bridge_mode ? 'true':'false', self.next_ep, self.configuration_version)
if self.debug
j += ',"debug":true'
end
@@ -553,6 +559,7 @@ class Matter_Device
self.ipv4only = bool(j.find("ipv4only", false))
self.disable_bridge_mode = bool(j.find("disable_bridge_mode", false))
self.next_ep = j.find("nextep", self.next_ep)
self.configuration_version = j.find("configuration_version", self.configuration_version)
self.plugins_config = j.find("config", nil)
self.debug = bool(j.find("debug"))
@@ -560,6 +567,12 @@ class Matter_Device
log(f"MTR: Load_config = {self.plugins_config}", 3)
self.adjust_next_ep()
dirty = self.check_config_ep()
# Endpoint repair changes the exposed composition just like an explicit
# add/remove operation, so controllers need a new ConfigurationVersion.
if dirty
self.configuration_version += 1
if self.configuration_version <= 0 self.configuration_version = 1 end
end
self.plugins_persist = true
else
self.plugins_config = {}
@@ -704,9 +717,11 @@ class Matter_Device
self.plugins_persist = true
self.next_ep += 1 # increment next allocated endpoint before saving
# Increment before persistence so ConfigurationVersion and endpoint config
# are committed in the same device record.
self.signal_endpoints_changed()
# try saving parameters
self.save_param()
self.signal_endpoints_changed()
return ep
end
@@ -743,15 +758,21 @@ class Matter_Device
# clean any orphan remote
self.clean_remotes()
# Persist the composition version together with the endpoint removal.
self.signal_endpoints_changed()
# try saving parameters
self.save_param()
self.signal_endpoints_changed()
end
#############################################################
# Signal to controller that endpoints changed via subcriptions
#
def signal_endpoints_changed()
# Basic Information ConfigurationVersion represents composition changes,
# while Descriptor PartsList invalidations refresh the affected topology.
self.configuration_version += 1
if self.configuration_version <= 0 self.configuration_version = 1 end
self.attribute_updated(0x0000, 0x0028, 0x0018, false)
# mark parts lists as changed
self.attribute_updated(0x0000, 0x001D, 0x0003, false)
var eps = self.get_active_endpoints(true)
@@ -0,0 +1,2 @@
# Host-test placeholder. The tested code paths do not perform cryptography.
return module("crypto")
@@ -0,0 +1,372 @@
#!/usr/bin/env -S ../../berry/berry -g
#
# Focused host-side tests for the Matter 1.6.1 metadata, fixed Tasmota ACL
# profile, Group Key Management, and Groups persistence.
#
import global
import os
import string
import sys
sys.path().push("../src/embedded")
import matter
# Firmware globals required while compiling the embedded sources.
var globs = "path,ctypes_bytes_dyn,tasmota,ccronexpr,gpio,light,webclient,load,MD5,lv,light_state,udp,tcpclientasync,log,"
"lv_clock,lv_clock_icon,lv_signal_arcs,lv_signal_bars,lv_wifi_arcs_icon,lv_wifi_arcs,"
"lv_wifi_bars_icon,_lvgl,"
for name : string.split(globs, ",")
global.(name) = nil
end
# Load embedded sources in the same order as solidify_all.be.
var files = os.listdir("../src/embedded")
for i : 1..size(files)-1
var value = files[i]
var j = i
while j > 0 && files[j-1] > value
files[j] = files[j-1]
j -= 1
end
files[j] = value
end
for name : files
if name[0] == '.' continue end
var source_file = open("../src/embedded/" + name)
var source = source_file.read()
source_file.close()
compile(source)()
end
matter.get_attribute_name = def (cluster, attribute) return nil end
class TestTasmota
var utc_time, local_time
def init()
self.utc_time = 1
self.local_time = 1700000000
end
def rtc_utc() return self.utc_time end
def rtc(mode) return self.local_time end
def millis() return 0 end
def loglevel(level) return false end
end
tasmota = TestTasmota()
log = def (*args) end
var crypto = module("crypto")
class TestCounter
var value
def init() self.value = 0 end
def next() self.value += 1 return self.value end
def reset(value) self.value = value ? value : 0 end
def val() return self.value end
static def is_greater(left, right) return left > right end
end
matter.Counter = TestCounter
class TestFabric : matter.Fabric
def persist_pre() end
def persist_post() end
def does_persist() return true end
end
matter.Fabric = TestFabric
# Avoid hardware-facing Root initialization while testing protocol helpers.
class TestRoot : matter.Plugin_Root
def init() self.endpoint = 0 end
end
class TestStore
var saves, fabrics
def init()
self.saves = 0
self.fabrics = []
end
def save_fabrics() self.saves += 1 end
end
class TestSession
var fabric
def init(fabric) self.fabric = fabric end
def is_PASE() return false end
def get_fabric() return self.fabric end
def get_fabric_index() return self.fabric ? self.fabric.get_fabric_index() : nil end
end
class TestDevice
static var GROUP_TRANSPORT_READY = true
var plugins, configuration_version, sessions
def init(plugin)
self.plugins = [plugin]
self.configuration_version = 1
end
def attribute_updated(endpoint, cluster, attribute, fabric_specific) end
def k2l(value)
var result = []
for key : value.keys() result.push(key) end
for i : 1..size(result)-1
var current = result[i]
var j = i
while j > 0 && result[j-1] > current
result[j] = result[j-1]
j -= 1
end
result[j] = current
end
return result
end
end
class TestNoGroupDevice
static var GROUP_TRANSPORT_READY = false
end
class TestEndpointPlugin : matter.Plugin_Device
def init(device)
self.device = device
self.endpoint = 1
end
end
class TestMessage
var exchange_id, session
def init(exchange_id, session)
self.exchange_id = exchange_id
self.session = session
end
end
class TestConfigurationDevice : matter.Device
var configuration_version, updates
def init()
self.configuration_version = 1
self.updates = 0
end
def attribute_updated(endpoint, cluster, attribute, fabric_specific)
self.updates += 1
end
def get_active_endpoints(exclude_zero) return [] end
end
var TLV = matter.TLV
var root = TestRoot()
var store = TestStore()
var fabric = matter.Fabric(store)
fabric.set_fabric_index(1)
var session = TestSession(fabric)
var device = TestDevice(root)
device.sessions = store
root.device = device
store.fabrics = [fabric]
# Matter 1.6.1 metadata and GKM attributes are directly readable.
var ctx = matter.Path(0, 0x0028, 0x0000)
assert(root.read_attribute(session, ctx, TLV.Matter_TLV_item()).val == 21)
ctx.attribute = 0x0015
assert(root.read_attribute(session, ctx, TLV.Matter_TLV_item()).val == 0x01060100)
ctx.attribute = 0x0018
assert(root.read_attribute(session, ctx, TLV.Matter_TLV_item()).val == 1)
assert(matter.StatusResponseMessage().InteractionModelRevision == 13)
# Time Synchronization attributes use the Matter epoch (2000-01-01), not Unix.
tasmota.utc_time = 1790675381
tasmota.local_time = 1790678981
ctx = matter.Path(0, 0x0038, 0x0000)
assert(root.read_attribute(session, ctx, TLV.Matter_TLV_item()).val ==
int64.fromstring("843990581000000"))
ctx.attribute = 0x0007
assert(root.read_attribute(session, ctx, TLV.Matter_TLV_item()).val ==
int64.fromstring("843994181000000"))
tasmota.utc_time = 1
ctx.attribute = 0x0000
assert(root.read_attribute(session, ctx, TLV.Matter_TLV_item()).typ == 0x14)
print(" Matter epoch conversion for UTC and local time: OK")
ctx = matter.Path(0, 0x003F, 0xFFFB)
var attribute_list = root.read_attribute(session, ctx, TLV.Matter_TLV_item())
var attribute_ids = []
for item : attribute_list.val attribute_ids.push(item.val) end
assert(attribute_ids.find(0) != nil)
assert(attribute_ids.find(1) != nil)
assert(attribute_ids.find(2) != nil)
assert(attribute_ids.find(3) != nil)
print(" Matter 1.6.1 metadata and GKM declaration: OK")
# Tasmota exposes one fixed wildcard CASE/Administer entry. Commissioner ACL
# writes are successful compatibility no-ops and cannot change that entry.
ctx = matter.Path(0, 0x001F, 0)
var acl = root.read_attribute(session, ctx, TLV.Matter_TLV_item())
assert(size(acl.val) == 1)
var acl_entry = acl.val[0]
assert(acl_entry.findsubval(1) == 5)
assert(acl_entry.findsubval(2) == 2)
assert(acl_entry.findsub(3).typ == 0x14)
assert(acl_entry.findsub(4).typ == 0x14)
assert(acl_entry.findsubval(0xFE) == 1)
var commissioner_acl = TLV.Matter_TLV_array()
var commissioner_entry = commissioner_acl.add_struct(nil)
commissioner_entry.add_TLV(1, 0x04, 5)
commissioner_entry.add_TLV(2, 0x04, 2)
var commissioner_subjects = commissioner_entry.add_array(3)
commissioner_subjects.add_TLV(nil, 0x07, int64.fromu32(0x12345678))
commissioner_entry.add_TLV(4, 0x14, nil)
assert(root.write_attribute(session, ctx, commissioner_acl.val))
assert(ctx.status == nil)
var acl_after_write = root.read_attribute(session, ctx, TLV.Matter_TLV_item())
assert(size(acl_after_write.val) == 1)
assert(acl_after_write.val[0].findsubval(1) == 5)
assert(acl_after_write.val[0].findsubval(2) == 2)
assert(acl_after_write.val[0].findsub(3).typ == 0x14)
assert(acl_after_write.val[0].findsub(4).typ == 0x14)
ctx.attribute = 4
assert(root.read_attribute(session, ctx, TLV.Matter_TLV_item()).val == 1)
print(" fixed wildcard ACL and commissioner write compatibility: OK")
# AttributePathIB must preserve absent, null (append), and numeric ListIndex.
var path_tlv = TLV.Matter_TLV_list()
path_tlv.add_TLV(2, 0x05, 0)
path_tlv.add_TLV(3, 0x06, 0x003F)
path_tlv.add_TLV(4, 0x06, 0)
var decoded_path = matter.AttributePathIB().from_TLV(path_tlv)
assert(!decoded_path.list_index_present)
path_tlv.add_TLV(5, 0x14, nil)
decoded_path = matter.AttributePathIB().from_TLV(path_tlv)
assert(decoded_path.list_index_present && decoded_path.list_index_is_null)
print(" ListIndex decoding for chunked GroupKeyMap writes: OK")
# Install a valid key set and verify persistence restores uint64 epoch times.
var write = TLV.Matter_TLV_struct()
var key_set = write.add_struct(0)
key_set.add_TLV(0, 0x05, 7)
key_set.add_TLV(1, 0x04, 0)
key_set.add_TLV(2, 0x10, bytes("00112233445566778899AABBCCDDEEFF"))
key_set.add_TLV(3, 0x07, int64(1000))
key_set.add_TLV(4, 0x14, nil)
key_set.add_TLV(5, 0x14, nil)
key_set.add_TLV(6, 0x14, nil)
key_set.add_TLV(7, 0x14, nil)
ctx = matter.Path(0, 0x003F, nil)
ctx.command = 0
assert(root.invoke_request(session, write, ctx) == true)
assert(fabric.find_group_key_set(7) != nil)
import json
var persisted = json.load(json.dump(fabric.get_group_key_sets()))
var reloaded = matter.Fabric(store)
reloaded.group_key_sets = persisted
reloaded.hydrate_post()
assert(isinstance(reloaded.find_group_key_set(7).find("time0"), int64))
# Malformed persisted epoch strings are ignored instead of aborting hydration
# and preventing this fabric and every following fabric from loading.
assert(matter.Fabric.uint64_from_json("damaged") == nil)
var damaged = matter.Fabric(store)
damaged.group_key_sets = [{"id": 8, "time0": "damaged"}]
damaged.hydrate_post()
assert(damaged.find_group_key_set(8) != nil)
print(" Group key-set write and persistence: OK")
# KeySetRead redacts key material and KeySetReadAllIndices includes IPK ID zero.
write = TLV.Matter_TLV_struct()
write.add_TLV(0, 0x05, 7)
ctx.command = 1
var response = root.invoke_request(session, write, ctx)
assert(ctx.command == 2)
assert(response.findsub(0).findsubval(0) == 7)
assert(response.findsub(0).findsub(2).typ == 0x14)
ctx.command = 4
response = root.invoke_request(session, TLV.Matter_TLV_struct(), ctx)
assert(ctx.command == 5)
assert(response.findsub(0).val[0].val == 0)
print(" Group key-set read and index enumeration: OK")
# GroupKeyMap uses atomic empty-list-plus-append staging.
var map_entry = TLV.Matter_TLV_struct()
map_entry.add_TLV(1, 0x05, 2)
map_entry.add_TLV(2, 0x05, 7)
var msg = TestMessage(40, session)
root.begin_write_request(msg)
ctx = matter.Path(0, 0x003F, 0)
ctx.list_index_present = false
ctx.list_write_final = false
assert(root.write_attribute(session, ctx, []))
ctx.list_index_present = true
ctx.list_index_is_null = true
ctx.list_write_final = true
ctx.write_tlv = map_entry
assert(root.write_attribute(session, ctx, map_entry.val))
root.end_write_request(msg, false)
assert(size(fabric.get_group_key_map()) == 1)
assert(fabric.get_group_key_map()[0].find("group_id") == 2)
# A pending map is also committed when the final message has no map operation.
var map_entry_2 = TLV.Matter_TLV_struct()
map_entry_2.add_TLV(1, 0x05, 3)
map_entry_2.add_TLV(2, 0x05, 7)
msg = TestMessage(41, session)
root.begin_write_request(msg)
ctx.list_index_present = false
ctx.list_index_is_null = false
ctx.list_write_final = false
assert(root.write_attribute(session, ctx, []))
ctx.list_index_present = true
ctx.list_index_is_null = true
ctx.write_tlv = map_entry_2
assert(root.write_attribute(session, ctx, map_entry_2.val))
root.end_write_request(msg, true)
root.begin_write_request(TestMessage(41, session))
root.end_write_request(TestMessage(41, session), false)
assert(fabric.get_group_key_map()[0].find("group_id") == 3)
print(" atomic and multi-message GroupKeyMap writes: OK")
# Removing a key set also removes mappings that reference it.
ctx = matter.Path(0, 0x003F, nil)
ctx.command = 3
write = TLV.Matter_TLV_struct()
write.add_TLV(0, 0x05, 7)
assert(root.invoke_request(session, write, ctx) == true)
assert(fabric.find_group_key_set(7) == nil)
assert(size(fabric.get_group_key_map()) == 0)
print(" Group key-set removal and map cleanup: OK")
# Production remains fail-closed until encrypted multicast receive is ready.
var no_group_device = TestNoGroupDevice()
root.device = no_group_device
ctx = matter.Path(0, 0x003F, 2)
assert(root.read_attribute(session, ctx, TLV.Matter_TLV_item()).val == 0)
ctx.attribute = 0
ctx.list_index_present = false
ctx.list_write_final = true
assert(!root.write_attribute(session, ctx, []))
assert(ctx.status == 0x88)
var endpoint_plugin = TestEndpointPlugin(no_group_device)
var add_group = TLV.Matter_TLV_struct()
add_group.add_TLV(0, 0x05, 4)
add_group.add_TLV(1, 0x0C, "Unavailable")
ctx = matter.Path(1, 0x0004, nil)
ctx.command = 0
assert(endpoint_plugin.invoke_request(session, add_group, ctx).findsubval(0) == 1)
# AddGroupIfIdentifying is mandatory and must succeed as a no-op when the
# endpoint is not identifying, even while multicast transport is disabled.
var add_if_identifying = TLV.Matter_TLV_struct()
add_if_identifying.add_TLV(0, 0x05, 4)
add_if_identifying.add_TLV(1, 0x0C, "Unavailable")
ctx.command = 5
assert(endpoint_plugin.invoke_request(session, add_if_identifying, ctx) == true)
add_if_identifying = TLV.Matter_TLV_struct()
add_if_identifying.add_TLV(0, 0x05, 0)
ctx.command = 5
assert(endpoint_plugin.invoke_request(session, add_if_identifying, ctx) == nil)
assert(ctx.status == 0x87)
print(" unavailable group transport fails closed: OK")
# ConfigurationVersion changes with endpoint composition and is persisted.
var config_device = TestConfigurationDevice()
config_device.signal_endpoints_changed()
assert(config_device.configuration_version == 2)
assert(config_device.updates == 2)
print(" dynamic ConfigurationVersion: OK")
print("Matter fixed-ACL and Group Key Management tests: OK")