mirror of
https://github.com/arendst/Tasmota.git
synced 2026-10-27 06:22:25 +00:00
Matter v1.6.1 more improvements (#25086)
This commit is contained in:
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")
|
||||
Reference in new issue
Block a user