mirror of
https://github.com/arendst/Tasmota.git
synced 2026-10-27 06:22:25 +00:00
Matter physical and virtual Garage Door device types with Closure Control cluster (#25089)
* Matter physical and virtual Garage Door device types with Closure Control cluster * fix CHANGELOG
This commit is contained in:
1 parent
dfa00bc969
commit
ddc0ae61d9
12 files changed
+1032
-49
No files matched your search
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
|
||||
- DALI-2 push button bindings to control gear targets with command `DaliBind` (toggle, on, off, up, down) (#25073)
|
||||
- Matter Electrical Power Measurement cluster to On/Off Plug-in Unit (#24922)
|
||||
- Matter Soil Sensor device type with Soil Measurement cluster (Matter 1.6.0) (#25088)
|
||||
- Matter physical and virtual Garage Door device types with Closure Control cluster (Matter 1.6.0)
|
||||
|
||||
### Breaking Changed
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ The latter links can be used for OTA upgrades too like ``OtaUrl https://ota.tasm
|
||||
- Berry `sortedmap` support for `json.dump` [#24999](https://github.com/arendst/Tasmota/issues/24999)
|
||||
- Matter Electrical Power Measurement cluster to On/Off Plug-in Unit [#24922](https://github.com/arendst/Tasmota/issues/24922)
|
||||
- Matter Soil Sensor device type with Soil Measurement cluster (Matter 1.6.0) [#25088](https://github.com/arendst/Tasmota/issues/25088)
|
||||
- Matter physical and virtual Garage Door device types with Closure Control cluster (Matter 1.6.0)
|
||||
|
||||
### Breaking Changed
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ This document tracks major specification changes from Matter 1.4.1 (current Tasm
|
||||
- **Closure Dimension (0x0105, Rev 1):** Controls individual axis (lift, tilt, rotation, modulation)
|
||||
- Semantic tags enable device differentiation (Window, Covering, Cabinet, etc.)
|
||||
- **Tasmota applicability:** ✅ High value; Tasmota Shutter relays, RF/IR controllers map naturally
|
||||
- ✅ **Garage Door implemented:** `Matter_Plugin_2_GarageDoor.be` (`garage`) — Closure (0x0230) +
|
||||
Closure Control (0x0104) only, driven by the same `ShutterPosition<x>` data as the legacy
|
||||
Shutter plugin. `Matter_Plugin_9_Virt_GarageDoor.be` (`v_garage`) provides the equivalent
|
||||
virtual endpoint through `MtrUpdate`. Window Covering (0x0202, `shutter`) kept unchanged for blinds/shutters.
|
||||
Closure Panel (0x0231) + Closure Dimension (0x0105) not implemented (not needed for a
|
||||
Closed/Open/Partial garage door). Reported controller support (unverified): Samsung
|
||||
SmartThings supports Closure Control; Home Assistant support in progress (September 2026).
|
||||
|
||||
#### 3. **Energy Management Framework** ✅ **IN SCOPE**
|
||||
- New clusters for real-time energy optimization, pricing, tariff, and carbon awareness
|
||||
@@ -233,14 +240,15 @@ The following changes are part of Matter 1.6.1 (released September 2026, PR #735
|
||||
- **Data source:** none native in Tasmota; values would be pushed via `MtrUpdate` (rules/scripts/MQTT)
|
||||
|
||||
### Phase 3: Closures Unified Architecture (v1.6.0, medium-value features)
|
||||
- Closure device type (0x0230) with Closure Control (0x0104) and Closure Dimension (0x0105) clusters
|
||||
- ✅ Closure device type (0x0230) with Closure Control (0x0104) cluster (Garage Door, `garage`)
|
||||
- Closure Dimension (0x0105) cluster (not implemented)
|
||||
- Closure Panel child device type (0x0231)
|
||||
- Migration path for existing Window Covering/Shutter support
|
||||
- **Timeframe:** 2-3 weeks
|
||||
- **Integration:** Replaces/enhances current scattered cluster approach
|
||||
|
||||
### Phase 4: Sensors & Doorbells (v1.6.0, low-priority features)
|
||||
- Soil Sensor device type (0x0045) with Soil Measurement cluster (0x0430)
|
||||
- ✅ Soil Sensor device type (0x0045) with Soil Measurement cluster (0x0430)
|
||||
- Doorbell device type (0x0148) — optional, can defer to maintenance release
|
||||
- **Timeframe:** 1 week
|
||||
- **Integration:** Extends existing humidity/temperature sensor plugins
|
||||
|
||||
@@ -88,7 +88,14 @@ The Tasmota Matter implementation is currently aligned with **Matter 1.4.1** (Da
|
||||
- Current `Matter_Plugin_2_Shutter.be` (Window Covering, 0x0202) and `Matter_Plugin_2_Thermostat.be` (Door Lock, 0x0301) use legacy clusters
|
||||
- Closure architecture allows unified control of: window coverings (shutters), doors, garage doors, cabinets, gates
|
||||
- Tasmota devices (Shutter relays, RF/IR remote controls) map naturally to Closure model
|
||||
- **Estimated effort**: Medium (1-2 weeks) — restructure Window Covering + extend to Door Lock; test cross-controller compatibility
|
||||
- ✅ **Garage Door implemented**: `Matter_Plugin_2_GarageDoor.be` (`garage`) exposes Closure (0x0230, Rev 1)
|
||||
+ Closure Control (0x0104, Rev 1, feature PS) only, reusing the Shutter's `ShutterPosition<x>` /
|
||||
ShutterInvert data source but reporting MainState/OverallCurrentState/OverallTargetState instead
|
||||
of the legacy Window Covering attributes. Descriptor TagList carries the Closure namespace
|
||||
GarageDoor tag. `Matter_Plugin_9_Virt_GarageDoor.be` (`v_garage`) exposes the same Matter model
|
||||
with state supplied through `MtrUpdate`. Window Covering (0x0202) is left untouched for shutters/blinds.
|
||||
- **Remaining effort**: Low-Medium (3-5 days) — Closure Panel (0x0231) + Closure Dimension (0x0105)
|
||||
only needed if a future closure requires percentage lift/tilt; Door Lock migration still open
|
||||
|
||||
### 1.2 Soil Sensor — NEW SIMPLE DEVICE TYPE
|
||||
|
||||
@@ -147,7 +154,7 @@ The Tasmota Matter implementation is currently aligned with **Matter 1.4.1** (Da
|
||||
|---|---|---|---|---|---|
|
||||
| Commodity Price | 0x0095 | 4 | Base | SEPR | Real-time/forecasted pricing for gas, energy, water |
|
||||
| Commodity Tariff | 0x0700 | 1 | Base | SETRF | Tariff schedules and rate structure |
|
||||
| Device Energy Management | 0x0098 | — | Base | — | Power adjustment, demand response, forecasting |
|
||||
| Device Energy Management | 0x0098 | 4 | Base | DEM | Power adjustment, demand response, forecasting |
|
||||
| Energy EVSE | 0x0099 | — | Base | — | EV charging control (out of scope for Tasmota) |
|
||||
| Energy EVSE Mode | 0x009D | — | Base | — | EVSE mode switching |
|
||||
| Water Heater Management | 0x0094 | — | Base | — | Water heater control (out of scope for Tasmota) |
|
||||
@@ -247,52 +254,61 @@ The Tasmota Matter implementation is currently aligned with **Matter 1.4.1** (Da
|
||||
|
||||
**Objective**: Expose Tasmota's extensive energy drivers through Matter Energy Management framework.
|
||||
|
||||
1. **Commodity Price Cluster (0x0095, Rev 4)**
|
||||
- Purpose: Receive pricing signals from grid/utility device
|
||||
- File: New `Matter_Plugin_Energy_Price.be` or add to Root Node if it's a global cluster
|
||||
- Attributes: Tiers, pricing matrix, forecasts
|
||||
- Effort: 1 week
|
||||
1. ⏹️ **Device Energy Management Cluster (0x0098, Rev 4)** — not applied to plugs
|
||||
- Not required by On/Off Plug-in Unit (0x010A) nor Electrical Sensor (0x0510); DEM belongs to Energy Smart Appliances (device type 0x050D, Rev 3: EVSE, water heater, heat pump, battery, solar)
|
||||
- Reporting-only DEM on a plug (static ESAState, no adjustment) brings no value to controllers
|
||||
- Revisit only if a Tasmota plugin models an actual ESA with load control
|
||||
|
||||
2. **Commodity Tariff Cluster (0x0700, Rev 1)**
|
||||
- Purpose: Receive tariff schedule (time-of-use, peak hours, etc.)
|
||||
- File: New `Matter_Plugin_Energy_Tariff.be`
|
||||
- Attributes: Tariff type, pricing tiers, effective dates
|
||||
- Effort: 1 week
|
||||
### Phase 2b: Commodity Price & Tariff (deferred)
|
||||
|
||||
3. **Device Energy Management Cluster (0x0098)**
|
||||
- Purpose: Expose power measurement + demand response hints
|
||||
- File: Extend `Matter_Plugin_3_OnOff_Power.be` or create new `Matter_Plugin_Sensor_Power.be`
|
||||
- Attributes: CurrentPower, PowerMinimum, PowerMaximum, PowerAdjustmentCapability, etc.
|
||||
- Effort: 1-2 weeks
|
||||
1. **Commodity Price (0x0095, Rev 4)** and **Commodity Tariff (0x0700, Rev 1)**
|
||||
- Hosted only by Electrical Energy Tariff (0x0513), a child endpoint of Meter Reference Point (0x0512, with Identify)
|
||||
- Requires Descriptor TagList semantic tags (Commodity Tariff namespaces; Grid/Import/AC/Current in the basic topology) and TimeSyncCond on the Root Node
|
||||
- Commodity Tariff: 14+ attributes with deeply nested structs (DayEntry, DayPattern, CalendarPeriod, TariffComponent, TariffPeriod)
|
||||
- Prerequisites: Namespace Specification 1.6, parent/child virtual composition, TagList support
|
||||
- No native Tasmota data source; values would be pushed via `MtrUpdate`
|
||||
|
||||
### Phase 3: Closures Unified Architecture (Weeks 6-8)
|
||||
|
||||
**Objective**: Refactor Window Covering and Door Lock to new Closure model.
|
||||
|
||||
1. **Closure Device Type (0x0230) — Parent**
|
||||
- File: New `Matter_Plugin_Closure.be`
|
||||
- Clusters: Descriptor, Identify, Closure Control (0x0104), Closure Dimension (0x0105)
|
||||
- Effort: 1-2 weeks (design + implementation)
|
||||
1. ✅ **Closure Device Type (0x0230, Rev 1) — Garage Door** — implemented
|
||||
- File: `Matter_Plugin_2_GarageDoor.be` (`garage`)
|
||||
- Clusters: Descriptor (TagList: Closure/GarageDoor), Identify (inherited), Closure Control (0x0104, Rev 1, PS)
|
||||
- MainState (Stopped/Moving), OverallCurrentState/OverallTargetState (Current/TargetPositionEnum),
|
||||
Stop (0x00) and MoveTo (0x01, timed invoke enforced) commands, MovementCompleted and
|
||||
SecureStateChanged events, fed by the same `ShutterPosition<x>` / ShutterInvert logic as the
|
||||
legacy Shutter plugin; MoveToSignaturePosition maps to fully open
|
||||
- Limitation: SecureState (= FullyClosed) follows Tasmota's time-estimated position, not an
|
||||
end-stop sensor; moves outside Tasmota (obstruction reversal, car remote, wall button) are
|
||||
not detected, so SecureState=true is not a confirmed closed state
|
||||
- No Closure Panel child endpoint: garage doors are modelled as a single
|
||||
enum-position closure (Closed/Open/Partial), not a percentage lift axis
|
||||
- Effort: implemented directly (skipped generic Closure/Closure Panel scaffolding below)
|
||||
|
||||
2. **Closure Panel Device Type (0x0231) — Child**
|
||||
2. **Closure Panel Device Type (0x0231) — Child** (not yet implemented)
|
||||
- File: New `Matter_Plugin_Closure_Panel.be` (inherits from `Matter_Plugin_1_Device`)
|
||||
- Clusters: Descriptor, Identify, Closure Dimension (0x0105)
|
||||
- Needed only if a future closure requires percentage lift/tilt (e.g. gate, blind) composed as parent+panel
|
||||
- Effort: 3-5 days (follows Closure pattern)
|
||||
|
||||
3. **Migration Path for Existing Devices**
|
||||
- Current `Matter_Plugin_2_Shutter.be` (Window Covering, 0x0202) — keep as-is or migrate to new Closure model?
|
||||
- Option A: Keep as legacy (backward compatibility)
|
||||
- Option B: Refactor to Closure (cleaner, aligned with v1.6 spec)
|
||||
- Decision deferred to implementation phase (depends on customer device install base)
|
||||
- Current `Matter_Plugin_2_Shutter.be` (Window Covering, 0x0202) — kept as-is (Option A,
|
||||
legacy, widest controller support today) alongside the new `garage` Closure plugin (Option B)
|
||||
- Rationale: Window Covering (0x0202) remains the safer choice for shutters/blinds until
|
||||
Closure Control (0x0104) support is broader across controllers; Garage Door has no legacy
|
||||
Matter device type at all, so it was implemented directly against Closure (0x0230)
|
||||
- Reported controller support (unverified): Samsung SmartThings supports Closure Control; Home
|
||||
Assistant support is in progress (September 2026)
|
||||
|
||||
### Phase 4: Soil Sensor and Doorbells (Weeks 9-10)
|
||||
|
||||
**Objective**: Add simple sensor and doorbell device types for completeness.
|
||||
|
||||
1. **Soil Sensor (0x0045)**
|
||||
- File: New `Matter_Plugin_Sensor_Soil.be`
|
||||
- Reuses existing humidity/temperature plugins as templates
|
||||
- Effort: 3-5 days
|
||||
1. ✅ **Soil Sensor (0x0045, Rev 1)** — implemented
|
||||
- Files: `Matter_Plugin_3_Sensor_Soil.be` (`soil`), `Matter_Plugin_9_Virt_Sensor_Soil.be` (`v_soil`), `Matter_Plugin_8_Bridge_Sensor_Soil.be` (`http_soil`, `mqtt_soil`)
|
||||
- Soil Measurement (0x0430, Rev 1): SoilMoistureMeasurementLimits + SoilMoistureMeasuredValue, fed by the Tasmota `Moisture` JSON key (percent)
|
||||
- Optional Temperature Measurement (0x0402) not composed on the same endpoint
|
||||
|
||||
2. **Doorbell Device Type (0x0148) — Optional**
|
||||
- File: New `Matter_Plugin_Doorbell.be`
|
||||
@@ -317,12 +333,12 @@ The Tasmota Matter implementation is currently aligned with **Matter 1.4.1** (Da
|
||||
|
||||
| File | Purpose | Priority | Phase |
|
||||
|---|---|---|---|---|
|
||||
| `Matter_Plugin_Energy_Price.be` | Commodity Price cluster | HIGH | 2 |
|
||||
| `Matter_Plugin_Energy_Tariff.be` | Commodity Tariff cluster | HIGH | 2 |
|
||||
| `Matter_Plugin_Sensor_Power.be` | Device Energy Management | HIGH | 2 |
|
||||
| `Matter_Plugin_Closure.be` | Closure device type (parent) | MEDIUM | 3 |
|
||||
| `Matter_Plugin_Closure_Panel.be` | Closure Panel device type (child) | MEDIUM | 3 |
|
||||
| `Matter_Plugin_Sensor_Soil.be` | Soil Sensor device type | MEDIUM | 4 |
|
||||
| `Matter_Plugin_Energy_Price.be` | Commodity Price cluster | DEFERRED | 2b |
|
||||
| `Matter_Plugin_Energy_Tariff.be` | Commodity Tariff cluster | DEFERRED | 2b |
|
||||
| `Matter_Plugin_Sensor_Power.be` | Device Energy Management — not applicable to plugs (ESA only) | N/A | 2 |
|
||||
| `Matter_Plugin_2_GarageDoor.be` | Closure device type (Garage Door, Closure Control only) | DONE | 3 |
|
||||
| `Matter_Plugin_Closure_Panel.be` | Closure Panel device type (child, percentage lift/tilt closures) | MEDIUM | 3 |
|
||||
| `Matter_Plugin_3_Sensor_Soil.be` | Soil Sensor device type | DONE | 4 |
|
||||
| `Matter_Plugin_Doorbell.be` | Doorbell device type | LOW | 4 |
|
||||
|
||||
### Files to Modify
|
||||
@@ -340,7 +356,7 @@ The Tasmota Matter implementation is currently aligned with **Matter 1.4.1** (Da
|
||||
| Category | 1.4.1 | 1.6.0 | Status | Effort |
|
||||
|---|---|---|---|---|
|
||||
| **DataModelRevision** | 18 | 20 | ⚠️ Not updated | <1 day |
|
||||
| **Closures clusters** | Window Covering (legacy) | Closure Control/Dimension (new) | ❌ Missing | 2 weeks |
|
||||
| **Closures clusters** | Window Covering (legacy) | Closure Control (Garage Door, `garage`) ✅ Done; Closure Dimension (panel-based closures) still missing | ⚠️ Partial | 3-5 days remaining |
|
||||
| **Energy Management** | 0x0090/0x0091 only | Add Commodity Price/Tariff, Device EM, EVSE | ❌ Missing | 3-4 weeks |
|
||||
| **Soil Measurement** | None | Soil Sensor (0x0045) + cluster | ✅ Done | — |
|
||||
| **Doorbell** | None | 0x0148/0x0141/0x0143 | ❌ Missing | 1 week |
|
||||
|
||||
@@ -229,6 +229,8 @@ extern const bclass be_class_Matter_TLV; // need to declare it upfront because
|
||||
#include "solidify/solidified_Matter_Plugin_9_Virt_Light3.h"
|
||||
#include "solidify/solidified_Matter_Plugin_2_Shutter.h"
|
||||
#include "solidify/solidified_Matter_Plugin_3_ShutterTilt.h"
|
||||
#include "solidify/solidified_Matter_Plugin_2_GarageDoor.h"
|
||||
#include "solidify/solidified_Matter_Plugin_9_Virt_GarageDoor.h"
|
||||
#include "solidify/solidified_Matter_Plugin_2_Sensor.h"
|
||||
#include "solidify/solidified_Matter_Plugin_3_Sensor_Pressure.h"
|
||||
#include "solidify/solidified_Matter_Plugin_3_Sensor_Flow.h"
|
||||
|
||||
@@ -935,7 +935,13 @@ class Matter_IM
|
||||
|
||||
var cmd_name = matter.get_command_name(ctx.cluster, ctx.command)
|
||||
var ctx_str = str(ctx) # keep string before invoking, it is modified by response
|
||||
var res = self.device.invoke_request(msg.session, q.command_fields, ctx)
|
||||
var res
|
||||
if !query.timed_request && self.command_needs_timed(ctx)
|
||||
ctx.status = 0xC6 #-matter.NEEDS_TIMED_INTERACTION-#
|
||||
ctx.log = "needs timed invoke"
|
||||
else
|
||||
res = self.device.invoke_request(msg.session, q.command_fields, ctx)
|
||||
end
|
||||
var params_log = (ctx.log != nil) ? "(" + str(ctx.log) + ") " : ""
|
||||
log(format("MTR: >Command (%6i) %s %s %s", msg.session.local_session_id, ctx_str, cmd_name ? cmd_name : "", params_log), 3)
|
||||
# log("MTR: Perf/Command = " + str(debug.counters()), 4)
|
||||
@@ -1003,7 +1009,13 @@ class Matter_IM
|
||||
|
||||
var cmd_name = matter.get_command_name(ctx.cluster, ctx.command)
|
||||
var ctx_str = str(ctx) # keep string before invoking, it is modified by response
|
||||
var res = self.device.invoke_request(msg.session, ctx.command_fields, ctx)
|
||||
var res
|
||||
if !ctx.TimedRequest && self.command_needs_timed(ctx)
|
||||
ctx.status = 0xC6 #-matter.NEEDS_TIMED_INTERACTION-#
|
||||
ctx.log = "needs timed invoke"
|
||||
else
|
||||
res = self.device.invoke_request(msg.session, ctx.command_fields, ctx)
|
||||
end
|
||||
var params_log = (ctx.log != nil) ? "(" + str(ctx.log) + ") " : ""
|
||||
if tasmota.loglevel(3)
|
||||
log(format("MTR: >Command1 (%6i) %s %s %s", msg.session.local_session_id, ctx_str, cmd_name ? cmd_name : "", params_log), 3)
|
||||
@@ -1246,6 +1258,29 @@ class Matter_IM
|
||||
return true
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# command_needs_timed
|
||||
#
|
||||
# Returns true if the concrete command path in `ctx` requires a Timed
|
||||
# Invoke (access quality `T`) and exists on the target endpoint.
|
||||
# Only timed commands implemented by Tasmota plugins are listed; an
|
||||
# unknown endpoint/cluster falls through to the normal UNSUPPORTED_* path.
|
||||
#
|
||||
# Called with a non-timed Invoke Request; the caller then answers
|
||||
# NEEDS_TIMED_INTERACTION (0xC6) for this command path.
|
||||
def command_needs_timed(ctx)
|
||||
var cluster = ctx.cluster
|
||||
var timed = false
|
||||
if cluster == 0x0104 # Closure Control
|
||||
timed = (ctx.command == 0x01) # MoveTo (O T)
|
||||
end
|
||||
if timed
|
||||
var pi = self.device.find_plugin_by_endpoint(ctx.endpoint)
|
||||
timed = (pi != nil) && pi.contains_cluster(cluster)
|
||||
end
|
||||
return timed
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# check_timed_request
|
||||
#
|
||||
|
||||
@@ -116,6 +116,7 @@ class Matter_Plugin
|
||||
0x0090: 0x02, # Electrical Power Measurement: AlternatingCurrent (bit 1)
|
||||
0x009C: 0x01, # Power Topology: NodeTopology (bit 0) - meters the whole node
|
||||
0x0102: 1 + 4, # Window Covering: Lift (bit 0) + PA_LF (bit 2)
|
||||
0x0104: 0x01, # Closure Control: Positioning (bit 0)
|
||||
0x0201: 0x23, # Thermostat: HEAT + COOL + AUTO
|
||||
0x0202: 2, # Fan Control: Auto (bit 1)
|
||||
}
|
||||
@@ -158,6 +159,7 @@ class Matter_Plugin
|
||||
0x0090: 1, # Electrical Power Measurement - Initial Release (Matter 1.3)
|
||||
0x0101: 7, # Door Lock - Added support for European door locks (unbolt feature)
|
||||
0x0102: 5, # Window Covering - New data model format and notation
|
||||
0x0104: 1, # Closure Control - Initial revision (Matter 1.5, unchanged in 1.6.1)
|
||||
0x0200: 4, # Pump Configuration and Control - Added feature map
|
||||
0x0201: 6, # Thermostat - Introduced the LTNE feature and adapted text (spec issue #5778)
|
||||
0x0202: 4, # Fan Control - Change conformance for FanModeSequence
|
||||
@@ -442,11 +444,9 @@ matter_device.events.dump()
|
||||
elif attribute == 0x0003 # ---------- PartsList / list[endpoint-no]----------
|
||||
var pl = TLV.Matter_TLV_array()
|
||||
return pl
|
||||
elif attribute == 0xFFFC # ---------- FeatureMap / map32 ----------
|
||||
return tlv_solo.set(0x06 #-TLV.U4-#, 0) #
|
||||
elif attribute == 0xFFFD # ---------- ClusterRevision / u2 ----------
|
||||
return tlv_solo.set(0x06 #-TLV.U4-#, 1) # "Initial Release"
|
||||
end
|
||||
# FeatureMap / ClusterRevision use the generic handlers below (CLUSTER_REVISIONS 0x001D = 2, TagList capable);
|
||||
# plugins exposing a TagList override 0x0004 and 0xFFFC (TAGLIST feature bit 0)
|
||||
|
||||
end
|
||||
|
||||
|
||||
@@ -507,6 +507,7 @@ class Matter_Plugin_Device : Matter_Plugin
|
||||
# shutters
|
||||
_stats_json_inner("shadow_shutter_pos", "ShutterPos")
|
||||
_stats_json_inner("shadow_shutter_target", "ShutterTarget")
|
||||
_stats_json_inner("shadow_shutter_direction", "ShutterDirection")
|
||||
_stats_json_inner("shadow_shutter_tilt", "ShutterTilt")
|
||||
|
||||
# sensors
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
#
|
||||
# Matter_Plugin_2_GarageDoor.be - implements the behavior for a Garage Door (Closure)
|
||||
#
|
||||
# Copyright (C) 2026 Ludovic BOUÉ
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
#################################################################################
|
||||
# Matter 1.6.1 Device Specification - Closure (0x0230)
|
||||
#################################################################################
|
||||
# Device Type: Closure (0x0230) - introduced in Matter 1.5.0
|
||||
# Device Type Revision: 1 (unchanged in Matter 1.6.1 Device Library)
|
||||
# Class: Simple | Scope: Endpoint
|
||||
#
|
||||
# The Closure device type is the unified umbrella (Matter 1.5.0+) for window
|
||||
# coverings, doors, gates, cabinets and garage doors (GDO). A garage door is a
|
||||
# Closure whose motion is exposed through the Closure Control cluster.
|
||||
#
|
||||
# CLUSTERS (Server):
|
||||
# - 0x0104: Closure Control (M) - unified movement/state interface
|
||||
# - 0x0003: Identify (M) - inherited from base class
|
||||
# - 0x001D: Descriptor (M) - TagList with exactly one Closure namespace tag
|
||||
#
|
||||
# DESCRIPTOR TAGLIST:
|
||||
# - A Closure SHALL carry exactly one tag from the Closure namespace (0x44).
|
||||
# This plugin exposes {MfgCode:null, NamespaceID:0x44, Tag:0x05 GarageDoor}
|
||||
# and sets the Descriptor TAGLIST feature (bit 0).
|
||||
#
|
||||
# NOTES:
|
||||
# - This plugin drives a position-aware Tasmota Shutter (relay based garage
|
||||
# door opener) and reports fully-open / fully-closed / partially-open state.
|
||||
# - Finer-grained percentage positioning would be modelled by a companion
|
||||
# Closure Panel (0x0231) endpoint with the Closure Dimension cluster (0x0105);
|
||||
# not implemented here (garage doors are typically enum-position only).
|
||||
#################################################################################
|
||||
|
||||
#################################################################################
|
||||
# Matter 1.6.1 Closure Control Cluster (0x0104)
|
||||
#################################################################################
|
||||
# Cluster Revision: 1 (initial revision in Matter 1.5.0, unchanged in 1.6.1)
|
||||
# Role: Application | Scope: Endpoint | PICS: CLCTRL
|
||||
#
|
||||
# FEATURES (this plugin):
|
||||
# - Bit 0 (PS): Positioning - FEATURE_MAPS[0x0104] = 0x01 in Matter_Plugin_0
|
||||
# No LT, IS, SP, VT, PD, CL, PT, MO.
|
||||
#
|
||||
# ATTRIBUTES:
|
||||
# ID | Name | Type | Quality | Conf
|
||||
# -------|----------------------|---------------------------|---------|-----
|
||||
# 0x0000 | CountdownTime | elapsed_s (uint32) | X Q | [PS&!IS]
|
||||
# 0x0001 | MainState | MainStateEnum (enum8) | | M
|
||||
# 0x0002 | CurrentErrorList | list[ClosureErrorEnum] | | M
|
||||
# 0x0003 | OverallCurrentState | OverallCurrentStateStruct | X | M
|
||||
# 0x0004 | OverallTargetState | OverallTargetStateStruct | X | M
|
||||
#
|
||||
# MainStateEnum(enum8): Stopped=0, Moving=1, WaitingForMotion=2, Error=3,
|
||||
# Calibrating=4, Protected=5, Disengaged=6, SetupRequired=7
|
||||
# CurrentPositionEnum(enum8): FullyClosed=0, FullyOpened=1, PartiallyOpened=2,
|
||||
# OpenedForPedestrian=3(PD), OpenedForVentilation=4(VT), OpenedAtSignature=5(M)
|
||||
# TargetPositionEnum(enum8): MoveToFullyClosed=0, MoveToFullyOpen=1,
|
||||
# MoveToPedestrianPosition=2(PD), MoveToVentilationPosition=3(VT),
|
||||
# MoveToSignaturePosition=4(M)
|
||||
#
|
||||
# OverallCurrentStateStruct: 0:Position(CurrentPositionEnum,X,PS) 1:Latch(bool,X,LT)
|
||||
# 2:Speed(ThreeLevelAutoEnum,SP) 3:SecureState(bool,X,M)
|
||||
# OverallTargetStateStruct : 0:Position(TargetPositionEnum,X,PS) 1:Latch(bool,X,LT)
|
||||
# 2:Speed(ThreeLevelAutoEnum,SP)
|
||||
#
|
||||
# COMMANDS:
|
||||
# ID | Name | Dir | Access | Conf | Notes
|
||||
# -----|-----------|------|--------|------|---------------------------
|
||||
# 0x00 | Stop | C→S | O | !IS | halt motion
|
||||
# 0x01 | MoveTo | C→S | O T | M | timed invoke enforced in Matter_IM
|
||||
#
|
||||
# MoveTo: {Position:TargetPositionEnum, Latch:bool, Speed:ThreeLevelAutoEnum} (O.a+)
|
||||
# - no field at all -> INVALID_COMMAND
|
||||
# - only Latch/Speed (no LT/SP) -> SUCCESS, no action
|
||||
# - Position 2/3 (no PD/VT) or out of range -> CONSTRAINT_ERROR
|
||||
#
|
||||
# EVENTS:
|
||||
# ID | Name | Priority | Conf | Emitted
|
||||
# -----|--------------------|----------|------|-------------------------------
|
||||
# 0x00 | OperationalError | CRIT | M | never (no error source in Tasmota)
|
||||
# 0x01 | MovementCompleted | INFO | !IS | Moving -> Stopped
|
||||
# 0x03 | SecureStateChanged | INFO | M | SecureState true <-> false
|
||||
#
|
||||
# TASMOTA IMPLEMENTATION:
|
||||
# - ARG: "shutter" - Tasmota Shutter number (zero based)
|
||||
# - Reads Position/Direction/Target from `ShutterPosition<x>`; honors
|
||||
# ShutterInvert (right-most char of `Status 13` SHT<x>.Opt)
|
||||
# - MoveTo FullyOpen / Signature -> ShutterOpen<n> (Signature = fully open)
|
||||
# - MoveTo FullyClosed -> ShutterClose<n>
|
||||
# - Stop -> ShutterStop<n>
|
||||
# - Position: Tasmota closed% (0=open .. 100=closed) -> Closed/Partial/Open enum
|
||||
# - Target: derived from Tasmota `Target` (reflects local moves too)
|
||||
# - SecureState: true when FullyClosed (spec formula for PS without LT)
|
||||
#
|
||||
# LIMITATION - SecureState is NOT a confirmed closed state:
|
||||
# - Tasmota time-based shutters have no end-stop sensor: the position is
|
||||
# estimated from relay run time, not measured.
|
||||
# - Moves made outside Tasmota are invisible: opener obstruction reversal
|
||||
# (photo-eye), car remote, wall button, or a pulse ignored by the opener.
|
||||
# Tasmota may then report FullyClosed / SecureState=true (and emit
|
||||
# SecureStateChanged) while the door is actually open.
|
||||
# - Controllers and automations must not rely on SecureState as a security
|
||||
# guarantee for this plugin.
|
||||
#################################################################################
|
||||
|
||||
import matter
|
||||
|
||||
# Matter plug-in for core behavior
|
||||
|
||||
#@ solidify:Matter_Plugin_GarageDoor,weak
|
||||
|
||||
class Matter_Plugin_GarageDoor : Matter_Plugin_Device
|
||||
static var TYPE = "garage" # name of the plug-in in json
|
||||
static var DISPLAY_NAME = "Garage Door" # display name of the plug-in
|
||||
|
||||
static var SCHEMA = "shutter|" # arg name
|
||||
"l:Shutter|" # label (display name)
|
||||
"t:i|" # type: int
|
||||
"h:Shutter<x> number (0 based)" # hint
|
||||
static var CLUSTERS = matter.consolidate_clusters(_class, {
|
||||
0x001D: [4], # Descriptor: add TagList to inherited [0,1,2,3]
|
||||
# 0x0003: inherited # Identify 1.2 p.16
|
||||
0x0104: [0,1,2,3,4], # Closure Control (Matter 1.5.0+)
|
||||
})
|
||||
static var TYPES = { 0x0230: 1 } # Closure - Matter 1.6.1 Device Library Rev 1
|
||||
|
||||
# MainStateEnum
|
||||
static var MS_STOPPED = 0
|
||||
static var MS_MOVING = 1
|
||||
# CurrentPositionEnum
|
||||
static var CP_CLOSED = 0
|
||||
static var CP_OPENED = 1
|
||||
static var CP_PARTIAL = 2
|
||||
static var CP_SIGNATURE = 5
|
||||
# TargetPositionEnum
|
||||
static var TP_CLOSE = 0
|
||||
static var TP_OPEN = 1
|
||||
static var TP_SIGNATURE = 4
|
||||
|
||||
var tasmota_shutter_index # Shutter number in Tasmota (zero based)
|
||||
var shadow_shutter_pos # last known position 0..100 (Tasmota convention)
|
||||
var shadow_shutter_target # last known target 0..100 (Tasmota convention)
|
||||
var shadow_shutter_direction # 1=opening -1=closing 0=not moving
|
||||
var shadow_shutter_inverted # 1=matter convention 0=must invert, -1=unknown
|
||||
var shadow_signature # true if the last Matter MoveTo was MoveToSignaturePosition
|
||||
|
||||
#############################################################
|
||||
# parse_configuration
|
||||
#
|
||||
def parse_configuration(config)
|
||||
super(self).parse_configuration(config)
|
||||
self.tasmota_shutter_index = config.find('shutter')
|
||||
if self.tasmota_shutter_index == nil self.tasmota_shutter_index = 0 end
|
||||
if self.VIRTUAL
|
||||
# Virtual garage doors use the native Tasmota convention:
|
||||
# 0 = fully closed, 100 = fully open.
|
||||
self.shadow_shutter_pos = 0
|
||||
self.shadow_shutter_target = 0
|
||||
self.shadow_shutter_direction = 0
|
||||
self.shadow_shutter_inverted = 0
|
||||
else
|
||||
self.shadow_shutter_inverted = -1
|
||||
end
|
||||
self.shadow_signature = false
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# Update "inverted" flag from `Status 13`
|
||||
#
|
||||
# `Status 13` returns nothing when SetOption80 is off: keep the flag
|
||||
# unknown (-1), positions are then reported as unknown (null).
|
||||
def update_inverted()
|
||||
var r_st13 = tasmota.cmd("Status 13", true) # issue `Status 13`
|
||||
if isinstance(r_st13, map)
|
||||
var sht = r_st13.find('StatusSHT')
|
||||
if isinstance(sht, map)
|
||||
var d = sht.find("SHT"+str(self.tasmota_shutter_index))
|
||||
if isinstance(d, map)
|
||||
d = d.find('Opt')
|
||||
if type(d) == 'string' && size(d) > 0
|
||||
self.shadow_shutter_inverted = (d[size(d)-1] == '1') ? 1 : 0 # inverted is the right-most character
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# Update shadow from Tasmota `ShutterPosition`
|
||||
#
|
||||
def update_shadow()
|
||||
if !self.VIRTUAL && !self.BRIDGE
|
||||
var prev = self.state_snapshot()
|
||||
var old_inverted = self.shadow_shutter_inverted
|
||||
self.update_inverted()
|
||||
var sp = tasmota.cmd("ShutterPosition" + str(self.tasmota_shutter_index + 1), true)
|
||||
# `sp` can be nil, `{"ShutterPosition<n>":"Locked"}` (ShutterLock) or
|
||||
# another shutter's report: only a payload actually applied by
|
||||
# update_sensors refreshes the position.
|
||||
if !self.update_sensors(sp) && (old_inverted != self.shadow_shutter_inverted)
|
||||
# Never reinterpret a cached position using a new inversion setting.
|
||||
self.shadow_shutter_pos = nil
|
||||
self.shadow_shutter_target = nil
|
||||
end
|
||||
self.report_state_changes(prev)
|
||||
end
|
||||
super(self).update_shadow()
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# to_closed_pct
|
||||
#
|
||||
# Converts a Tasmota position (0..100) to closed% (100 = fully closed,
|
||||
# 0 = fully open), honoring ShutterInvert. Returns nil if the value or the
|
||||
# inversion flag is unknown: guessing could report an open door as closed.
|
||||
def to_closed_pct(v)
|
||||
if v == nil || self.shadow_shutter_inverted == nil || self.shadow_shutter_inverted < 0
|
||||
return nil
|
||||
end
|
||||
return (self.shadow_shutter_inverted == 0) ? 100 - v : v
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# is_moving
|
||||
#
|
||||
def is_moving()
|
||||
return (self.shadow_shutter_direction != nil) && (self.shadow_shutter_direction != 0)
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# current_position_enum -> CurrentPositionEnum or nil
|
||||
#
|
||||
# Signature position is fully open: reported as OpenedAtSignature when
|
||||
# reached through MoveToSignaturePosition.
|
||||
def current_position_enum()
|
||||
var cp = self.to_closed_pct(self.shadow_shutter_pos)
|
||||
if cp == nil return nil end
|
||||
if cp >= 100 return self.CP_CLOSED end
|
||||
if cp <= 0 return self.shadow_signature ? self.CP_SIGNATURE : self.CP_OPENED end
|
||||
return self.CP_PARTIAL
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# target_position_enum -> TargetPositionEnum or nil
|
||||
#
|
||||
# Derived from the Tasmota target so that local moves (buttons, rules)
|
||||
# are reflected too. A partial target (e.g. after Stop) is not
|
||||
# representable by TargetPositionEnum and is reported as null.
|
||||
def target_position_enum()
|
||||
var tc = self.to_closed_pct(self.shadow_shutter_target)
|
||||
if tc == nil return nil end
|
||||
if tc >= 100 return self.TP_CLOSE end
|
||||
if tc <= 0 return self.shadow_signature ? self.TP_SIGNATURE : self.TP_OPEN end
|
||||
return nil
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# set_signature
|
||||
#
|
||||
# Change the signature flag and report affected attributes
|
||||
def set_signature(b)
|
||||
if b != self.shadow_signature
|
||||
self.shadow_signature = b
|
||||
self.attribute_updated(0x0104, 0x0003) # OverallCurrentState
|
||||
self.attribute_updated(0x0104, 0x0004) # OverallTargetState
|
||||
end
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# state_snapshot / report_state_changes
|
||||
#
|
||||
# Compare derived Matter state before/after a shadow update, report
|
||||
# changed attributes and emit the mandatory events.
|
||||
def state_snapshot()
|
||||
return [self.current_position_enum(), self.is_moving(), self.target_position_enum()]
|
||||
end
|
||||
|
||||
def report_state_changes(prev)
|
||||
var pos = self.current_position_enum()
|
||||
var moving = self.is_moving()
|
||||
var target = self.target_position_enum()
|
||||
var prev_pos = prev[0]
|
||||
if pos != prev_pos
|
||||
self.attribute_updated(0x0104, 0x0003) # OverallCurrentState
|
||||
# SecureStateChanged, only between known states (not at startup)
|
||||
if (prev_pos != nil) && (pos != nil) && ((prev_pos == self.CP_CLOSED) != (pos == self.CP_CLOSED))
|
||||
self.publish_event(0x0104, 0x03, 1 #-matter.EVENT_INFO-#, matter.TLV.Matter_TLV_item().set(0x08 #-TLV.BOOL-#, pos == self.CP_CLOSED))
|
||||
end
|
||||
end
|
||||
if moving != prev[1]
|
||||
self.attribute_updated(0x0104, 0x0001) # MainState
|
||||
if !moving # Moving -> Stopped
|
||||
self.publish_event(0x0104, 0x01, 1 #-matter.EVENT_INFO-#) # MovementCompleted, no field
|
||||
end
|
||||
end
|
||||
if target != prev[2]
|
||||
self.attribute_updated(0x0104, 0x0004) # OverallTargetState
|
||||
end
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# read an attribute
|
||||
#
|
||||
def read_attribute(session, ctx, tlv_solo)
|
||||
var TLV = matter.TLV
|
||||
var cluster = ctx.cluster
|
||||
var attribute = ctx.attribute
|
||||
|
||||
# ====================================================================================================
|
||||
if cluster == 0x0104 # ========== Closure Control (Matter 1.5.0+) ==========
|
||||
self.update_shadow_lazy()
|
||||
if attribute == 0x0000 # ---------- CountdownTime / elapsed_s ----------
|
||||
return tlv_solo.set(0x14 #-TLV.NULL-#, nil) # not supported -> null
|
||||
elif attribute == 0x0001 # ---------- MainState / MainStateEnum ----------
|
||||
return tlv_solo.set(0x06 #-TLV.U4-#, self.is_moving() ? self.MS_MOVING : self.MS_STOPPED)
|
||||
elif attribute == 0x0002 # ---------- CurrentErrorList / list ----------
|
||||
return TLV.Matter_TLV_array() # no errors
|
||||
elif attribute == 0x0003 # ---------- OverallCurrentState / struct ----------
|
||||
var pos = self.current_position_enum()
|
||||
if pos == nil
|
||||
return tlv_solo.set(0x14 #-TLV.NULL-#, nil)
|
||||
end
|
||||
var s = TLV.Matter_TLV_struct()
|
||||
s.add_TLV(0, 0x06 #-TLV.U4-#, pos) # Position (CurrentPositionEnum)
|
||||
# SecureState: fully closed per Tasmota's estimated position, not a
|
||||
# sensor-confirmed state (see LIMITATION in the header)
|
||||
s.add_TLV(3, 0x08 #-TLV.BOOL-#, pos == self.CP_CLOSED)
|
||||
return s
|
||||
elif attribute == 0x0004 # ---------- OverallTargetState / struct ----------
|
||||
var target = self.target_position_enum()
|
||||
if target == nil
|
||||
return tlv_solo.set(0x14 #-TLV.NULL-#, nil)
|
||||
end
|
||||
var s = TLV.Matter_TLV_struct()
|
||||
s.add_TLV(0, 0x06 #-TLV.U4-#, target) # Position (TargetPositionEnum)
|
||||
return s
|
||||
elif attribute == 0xFFF9 # ---------- AcceptedCommandList ----------
|
||||
var al = TLV.Matter_TLV_array()
|
||||
al.add_TLV(nil, 0x06 #-TLV.U4-#, 0x0000) # Stop
|
||||
al.add_TLV(nil, 0x06 #-TLV.U4-#, 0x0001) # MoveTo
|
||||
return al
|
||||
end
|
||||
|
||||
# ====================================================================================================
|
||||
elif cluster == 0x001D # ========== Descriptor Cluster 9.5 p.453 ==========
|
||||
if attribute == 0x0004 # ---------- TagList / list[SemanticTagStruct] ----------
|
||||
var tl = TLV.Matter_TLV_array()
|
||||
var t = tl.add_struct()
|
||||
t.add_TLV(0, 0x14 #-TLV.NULL-#, nil) # MfgCode: null (standard namespace)
|
||||
t.add_TLV(1, 0x06 #-TLV.U4-#, 0x44) # NamespaceID: Closure
|
||||
t.add_TLV(2, 0x06 #-TLV.U4-#, 0x05) # Tag: GarageDoor
|
||||
return tl
|
||||
elif attribute == 0xFFFC # ---------- FeatureMap / map32 ----------
|
||||
return tlv_solo.set(0x06 #-TLV.U4-#, 0x01) # TAGLIST (bit 0)
|
||||
end
|
||||
|
||||
end
|
||||
return super(self).read_attribute(session, ctx, tlv_solo)
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# run_shutter_command
|
||||
#
|
||||
# Execute a Tasmota shutter command and translate command rejection into
|
||||
# an Interaction Model status. In particular, ShutterLock answers "Locked"
|
||||
# without moving the shutter and must not be exposed as Matter SUCCESS.
|
||||
def run_shutter_command(command, signature, ctx)
|
||||
var was_moving = self.is_moving()
|
||||
var response = tasmota.cmd(command, true)
|
||||
if !isinstance(response, map)
|
||||
ctx.status = 0x01 #-matter.FAILURE-#
|
||||
ctx.log = command + ":NoResponse"
|
||||
return false
|
||||
end
|
||||
|
||||
var result = response.find(command)
|
||||
if result == "Locked"
|
||||
ctx.status = 0xCB #-matter.INVALID_IN_STATE-#
|
||||
ctx.log = command + ":Locked"
|
||||
return false
|
||||
end
|
||||
|
||||
# Moving commands return a Shutter<n> state object. Stop returns either
|
||||
# `{"ShutterStop":"Done"}` (no index) when the shutter was idle, or the
|
||||
# last position report, possibly of another shutter that stopped at the
|
||||
# same time. Only an unknown/invalid command (`{"Command":...}`) fails.
|
||||
var idx1 = str(self.tasmota_shutter_index + 1)
|
||||
var shutter = response.find("Shutter" + idx1)
|
||||
var stopped = (command == "ShutterStop" + idx1) && !response.contains("Command")
|
||||
if !stopped && !isinstance(shutter, map)
|
||||
ctx.status = 0x01 #-matter.FAILURE-#
|
||||
ctx.log = command + ":Failed"
|
||||
return false
|
||||
end
|
||||
|
||||
# A Stop on an already-idle shutter is a no-op and must not change an
|
||||
# OpenedAtSignature state into an ordinary FullyOpened state.
|
||||
if !stopped || was_moving
|
||||
self.set_signature(signature)
|
||||
end
|
||||
if isinstance(shutter, map)
|
||||
self.parse_sensors(response)
|
||||
end
|
||||
self.update_shadow()
|
||||
return true
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# run_virtual_move
|
||||
#
|
||||
# Virtual moves complete immediately, but expose a Moving -> Stopped
|
||||
# transition so the mandatory MovementCompleted event is generated.
|
||||
# Position values use the Tasmota convention (0 closed, 100 open).
|
||||
def run_virtual_move(target, signature)
|
||||
var destination = (target == self.TP_CLOSE) ? 0 : 100
|
||||
var prev = self.state_snapshot()
|
||||
var current = self.shadow_shutter_pos
|
||||
|
||||
self.shadow_shutter_target = destination
|
||||
self.shadow_signature = signature
|
||||
if (current != nil) && (current != destination)
|
||||
self.shadow_shutter_direction = (destination > current) ? 1 : -1
|
||||
self.report_state_changes(prev)
|
||||
prev = self.state_snapshot()
|
||||
self.shadow_shutter_pos = destination
|
||||
self.shadow_shutter_direction = 0
|
||||
self.report_state_changes(prev)
|
||||
else
|
||||
self.shadow_shutter_pos = destination
|
||||
self.shadow_shutter_direction = 0
|
||||
self.report_state_changes(prev)
|
||||
end
|
||||
|
||||
self.publish_command('ShutterPos', self.shadow_shutter_pos,
|
||||
'ShutterTarget', self.shadow_shutter_target,
|
||||
'ShutterDirection', self.shadow_shutter_direction)
|
||||
return true
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# stop_virtual
|
||||
#
|
||||
# Stop preserves an idle signature position, matching the physical
|
||||
# shutter behavior. During motion the current position becomes the target.
|
||||
def stop_virtual()
|
||||
if self.is_moving()
|
||||
var prev = self.state_snapshot()
|
||||
self.shadow_shutter_direction = 0
|
||||
self.shadow_shutter_target = self.shadow_shutter_pos
|
||||
self.shadow_signature = false
|
||||
self.report_state_changes(prev)
|
||||
end
|
||||
self.publish_command('ShutterPos', self.shadow_shutter_pos,
|
||||
'ShutterTarget', self.shadow_shutter_target,
|
||||
'ShutterDirection', self.shadow_shutter_direction)
|
||||
return true
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# Invoke a command
|
||||
#
|
||||
# returns `true` for SUCCESS, or `nil` with `ctx.status` set for an error
|
||||
def invoke_request(session, val, ctx)
|
||||
var cluster = ctx.cluster
|
||||
var command = ctx.command
|
||||
|
||||
# ====================================================================================================
|
||||
if cluster == 0x0104 # ========== Closure Control (Matter 1.5.0+) ==========
|
||||
var idx1 = str(self.tasmota_shutter_index + 1) # Tasmota shutter number is 1 based
|
||||
self.update_shadow_lazy()
|
||||
if command == 0x0000 # ---------- Stop ----------
|
||||
if self.VIRTUAL
|
||||
return self.stop_virtual()
|
||||
end
|
||||
return self.run_shutter_command("ShutterStop" + idx1, false, ctx) ? true : nil
|
||||
elif command == 0x0001 # ---------- MoveTo ----------
|
||||
# O.a+ : at least one field must be present
|
||||
if (val == nil) || !val.is_struct
|
||||
ctx.status = 0x85 #-matter.INVALID_COMMAND-#
|
||||
return nil
|
||||
end
|
||||
if (val.findsub(0) == nil) && (val.findsub(1) == nil) && (val.findsub(2) == nil)
|
||||
ctx.status = 0x85 #-matter.INVALID_COMMAND-#
|
||||
return nil
|
||||
end
|
||||
var pos_item = val.findsub(0)
|
||||
if pos_item == nil
|
||||
ctx.log = "no-op" # only Latch/Speed: LT/SP not supported, nothing to do
|
||||
return true
|
||||
end
|
||||
var target = pos_item.val # Position (TargetPositionEnum), not nullable
|
||||
if type(target) != 'int'
|
||||
ctx.status = 0x85 #-matter.INVALID_COMMAND-#
|
||||
return nil
|
||||
end
|
||||
if target == self.TP_CLOSE
|
||||
ctx.log = "MoveTo:Close"
|
||||
if self.VIRTUAL return self.run_virtual_move(target, false) end
|
||||
return self.run_shutter_command("ShutterClose" + idx1, false, ctx) ? true : nil
|
||||
elif target == self.TP_OPEN
|
||||
ctx.log = "MoveTo:Open"
|
||||
if self.VIRTUAL return self.run_virtual_move(target, false) end
|
||||
return self.run_shutter_command("ShutterOpen" + idx1, false, ctx) ? true : nil
|
||||
elif target == self.TP_SIGNATURE
|
||||
ctx.log = "MoveTo:Signature"
|
||||
if self.VIRTUAL return self.run_virtual_move(target, true) end
|
||||
return self.run_shutter_command("ShutterOpen" + idx1, true, ctx) ? true : nil
|
||||
else
|
||||
ctx.status = 0x87 #-matter.CONSTRAINT_ERROR-# # Pedestrian/Ventilation (no PD/VT) or out of range
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
else
|
||||
return super(self).invoke_request(session, val, ctx)
|
||||
end
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# update_sensors
|
||||
#
|
||||
# Apply the output from `ShutterPosition` or teleperiod without reporting.
|
||||
# This lets update_shadow change ShutterInvert and position atomically.
|
||||
def update_sensors(payload)
|
||||
if !isinstance(payload, map) return false end
|
||||
var v = payload.find("Shutter" + str(self.tasmota_shutter_index + 1))
|
||||
if !isinstance(v, map) return false end
|
||||
var val = v.find("Position")
|
||||
if type(val) == 'int' self.shadow_shutter_pos = val end
|
||||
val = v.find("Direction")
|
||||
if type(val) == 'int' self.shadow_shutter_direction = val end
|
||||
val = v.find("Target")
|
||||
if type(val) == 'int' self.shadow_shutter_target = val end
|
||||
# signature applies only while the door is heading to / resting at fully open
|
||||
if self.shadow_signature
|
||||
var tc = self.to_closed_pct(self.shadow_shutter_target)
|
||||
if (tc != nil) && (tc > 0)
|
||||
self.shadow_signature = false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# parse the output from `ShutterPosition` or teleperiod and report changes
|
||||
# Ex: `{"Shutter1":{"Position":50,"Direction":0,"Target":50,"Tilt":30}}`
|
||||
#
|
||||
# Only applies to local devices: `read_sensors()` is dispatched to every
|
||||
# plugin and contains `Shutter<n>` for each local shutter, which must not
|
||||
# overwrite virtual (`update_virtual()`) or bridged state.
|
||||
def parse_sensors(payload)
|
||||
if self.VIRTUAL || self.BRIDGE return end
|
||||
var prev = self.state_snapshot()
|
||||
if self.update_sensors(payload)
|
||||
self.report_state_changes(prev)
|
||||
end
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# update_virtual
|
||||
#
|
||||
# MtrUpdate fields use the same convention as Tasmota shutters:
|
||||
# ShutterPos: 0 closed .. 100 open
|
||||
# ShutterTarget: 0 closed .. 100 open
|
||||
# ShutterDirection: -1 closing, 0 stopped, 1 opening
|
||||
def update_virtual(payload)
|
||||
var prev = self.state_snapshot()
|
||||
var val = payload.find("ShutterPos")
|
||||
if val != nil
|
||||
val = int(val)
|
||||
if val != nil
|
||||
if val < 0 val = 0 end
|
||||
if val > 100 val = 100 end
|
||||
self.shadow_shutter_pos = val
|
||||
end
|
||||
end
|
||||
|
||||
val = payload.find("ShutterTarget")
|
||||
if val != nil
|
||||
val = int(val)
|
||||
if val != nil
|
||||
if val < 0 val = 0 end
|
||||
if val > 100 val = 100 end
|
||||
self.shadow_shutter_target = val
|
||||
if val != 100
|
||||
self.shadow_signature = false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
val = payload.find("ShutterDirection")
|
||||
if val != nil
|
||||
val = int(val)
|
||||
if val != nil
|
||||
self.shadow_shutter_direction = (val < 0) ? -1 : ((val > 0) ? 1 : 0)
|
||||
end
|
||||
end
|
||||
|
||||
self.report_state_changes(prev)
|
||||
super(self).update_virtual(payload)
|
||||
end
|
||||
|
||||
#############################################################
|
||||
# web_values
|
||||
#
|
||||
# Show values of the remote device as HTML
|
||||
def web_values()
|
||||
import webserver
|
||||
self.web_values_prefix() # display '| ' and name if present
|
||||
var pos = self.current_position_enum()
|
||||
var label = "Unknown"
|
||||
if pos == self.CP_CLOSED label = "Closed"
|
||||
elif pos == self.CP_OPENED || pos == self.CP_SIGNATURE label = "Open"
|
||||
elif pos == self.CP_PARTIAL label = "Partial"
|
||||
end
|
||||
webserver.content_send(format("🚪 %s%s", label, self.is_moving() ? " (moving)" : ""))
|
||||
end
|
||||
|
||||
end
|
||||
matter.Plugin_GarageDoor = Matter_Plugin_GarageDoor
|
||||
@@ -0,0 +1,51 @@
|
||||
#
|
||||
# Matter_Plugin_9_Virt_GarageDoor.be - virtual Matter Garage Door
|
||||
#
|
||||
# Copyright (C) 2026 Ludovic BOUÉ
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
#################################################################################
|
||||
# Matter 1.6.1 Virtual Closure - Garage Door
|
||||
#################################################################################
|
||||
# INHERITS FROM: Matter_Plugin_GarageDoor
|
||||
# TYPE: "v_garage" | VIRTUAL: true
|
||||
#
|
||||
# Matter MoveTo commands complete immediately in the local virtual state and
|
||||
# publish MtrReceived with ShutterPos, ShutterTarget and ShutterDirection.
|
||||
#
|
||||
# External state can be injected with:
|
||||
# MtrUpdate {"Name":"Garage","ShutterPos":100,
|
||||
# "ShutterTarget":100,"ShutterDirection":0}
|
||||
# MtrUpdate {"Name":"Garage","ShutterPos":50,
|
||||
# "ShutterTarget":0,"ShutterDirection":-1}
|
||||
#
|
||||
# Values follow the Tasmota shutter convention:
|
||||
# ShutterPos/Target: 0 = fully closed, 100 = fully open
|
||||
# ShutterDirection: -1 = closing, 0 = stopped, 1 = opening
|
||||
#################################################################################
|
||||
|
||||
import matter
|
||||
|
||||
#@ solidify:Matter_Plugin_Virt_GarageDoor,weak
|
||||
|
||||
class Matter_Plugin_Virt_GarageDoor : Matter_Plugin_GarageDoor
|
||||
static var TYPE = "v_garage"
|
||||
static var DISPLAY_NAME = "v.Garage Door"
|
||||
static var SCHEMA = nil
|
||||
static var VIRTUAL = true
|
||||
static var UPDATE_COMMANDS = matter.UC_LIST(_class, "ShutterPos", "ShutterTarget", "ShutterDirection")
|
||||
end
|
||||
matter.Plugin_Virt_GarageDoor = Matter_Plugin_Virt_GarageDoor
|
||||
@@ -187,12 +187,12 @@ class Matter_UI
|
||||
"</script>"
|
||||
|
||||
static var _CLASSES_TYPES_STD =
|
||||
"|relay|relay_power|light0|light1|light2|light3|shutter|shutter+tilt"
|
||||
"|relay|relay_power|light0|light1|light2|light3|shutter|shutter+tilt|garage"
|
||||
"|gensw_btn"
|
||||
"|temperature|pressure|illuminance|humidity|occupancy|onoff|contact|flow|rain|waterleak"
|
||||
"|airquality|soil"
|
||||
static var _CLASSES_TYPES_VIRTUAL =
|
||||
"-virtual|v_relay|v_relay_power|v_light0|v_light1|v_light2|v_light3"
|
||||
"-virtual|v_relay|v_relay_power|v_light0|v_light1|v_light2|v_light3|v_garage"
|
||||
"|v_fan|v_hvac|v_hvac_option"
|
||||
"|v_temp|v_pressure|v_illuminance|v_humidity|v_occupancy|v_contact|v_flow|v_rain|v_waterleak"
|
||||
"|v_airquality|v_soil"
|
||||
|
||||
@@ -41,17 +41,20 @@ end
|
||||
matter.get_attribute_name = def (cluster, attribute) return nil end
|
||||
|
||||
class TestTasmota
|
||||
var utc_time, local_time
|
||||
var utc_time, local_time, now
|
||||
def init()
|
||||
self.utc_time = 1
|
||||
self.local_time = 1700000000
|
||||
self.now = 0
|
||||
end
|
||||
def rtc_utc() return self.utc_time end
|
||||
def rtc(mode) return self.local_time end
|
||||
def millis() return 0 end
|
||||
def millis() return self.now end
|
||||
def time_reached(deadline) return self.now >= deadline end
|
||||
def loglevel(level) return false end
|
||||
end
|
||||
tasmota = TestTasmota()
|
||||
var test_tasmota = tasmota
|
||||
log = def (*args) end
|
||||
var crypto = module("crypto")
|
||||
|
||||
@@ -137,6 +140,74 @@ class TestMessage
|
||||
end
|
||||
end
|
||||
|
||||
class TestTimedSession
|
||||
var local_session_id
|
||||
def init(local_session_id)
|
||||
self.local_session_id = local_session_id
|
||||
end
|
||||
end
|
||||
|
||||
class TestGarageEvents
|
||||
var published
|
||||
def init() self.published = [] end
|
||||
def publish_event(endpoint, cluster, event_id, urgent, priority, data0, data1, data2)
|
||||
self.published.push(event_id)
|
||||
end
|
||||
end
|
||||
|
||||
class TestGarageDevice
|
||||
var tick, events
|
||||
def init()
|
||||
self.tick = 1
|
||||
self.events = TestGarageEvents()
|
||||
end
|
||||
def attribute_updated(endpoint, cluster, attribute, fabric_specific) end
|
||||
end
|
||||
|
||||
class TestGarageTasmota
|
||||
var locked, fail, position, direction, target, inverted
|
||||
def init()
|
||||
self.locked = false
|
||||
self.fail = false
|
||||
self.position = 50
|
||||
self.direction = 1
|
||||
self.target = 100
|
||||
self.inverted = false
|
||||
end
|
||||
def shutter_state()
|
||||
return {"Shutter1": {"Position": self.position, "Direction": self.direction, "Target": self.target, "Tilt": 0}}
|
||||
end
|
||||
def cmd(command, mute)
|
||||
if command == "Status 13"
|
||||
return {"StatusSHT": {"SHT0": {"Opt": self.inverted ? "00001" : "00000"}}}
|
||||
elif command == "ShutterPosition1"
|
||||
return self.shutter_state()
|
||||
end
|
||||
if self.fail return nil end
|
||||
if self.locked
|
||||
var response = {}
|
||||
response[command] = "Locked"
|
||||
return response
|
||||
end
|
||||
if command == "ShutterStop1"
|
||||
# Real driver: idle Stop answers ResponseCmndDone() without index
|
||||
if self.direction == 0 return {"ShutterStop": "Done"} end
|
||||
self.direction = 0
|
||||
self.target = self.position
|
||||
elif command == "ShutterClose1"
|
||||
self.direction = -1
|
||||
self.target = 0
|
||||
elif command == "ShutterOpen1"
|
||||
self.direction = 1
|
||||
self.target = 100
|
||||
else
|
||||
return nil
|
||||
end
|
||||
return self.shutter_state()
|
||||
end
|
||||
def loglevel(level) return false end
|
||||
end
|
||||
|
||||
class TestConfigurationDevice : matter.Device
|
||||
var configuration_version, updates
|
||||
def init()
|
||||
@@ -362,6 +433,166 @@ assert(endpoint_plugin.invoke_request(session, add_if_identifying, ctx) == nil)
|
||||
assert(ctx.status == 0x87)
|
||||
print(" unavailable group transport fails closed: OK")
|
||||
|
||||
# Timed interactions are keyed by exchange ID, expire, and are consumed once.
|
||||
var im = matter.IM(device)
|
||||
var timed_session = TestTimedSession(10)
|
||||
var timed_msg = TestMessage(55, timed_session)
|
||||
tasmota.now = 100
|
||||
im.timed_exchanges[timed_msg.exchange_id] = 200
|
||||
assert(im.check_timed_request(timed_msg, true))
|
||||
assert(!im.check_timed_request(timed_msg, true))
|
||||
assert(size(im.timed_exchanges) == 0)
|
||||
im.timed_exchanges[timed_msg.exchange_id] = 99
|
||||
assert(!im.check_timed_request(timed_msg, true))
|
||||
assert(size(im.timed_exchanges) == 0)
|
||||
print(" timed interactions are one-shot and expire: OK")
|
||||
|
||||
# A locked or failed Tasmota shutter command must not be reported as Matter
|
||||
# SUCCESS, and a rejected command must not mutate the signature-position flag.
|
||||
var garage_tasmota = TestGarageTasmota()
|
||||
tasmota = garage_tasmota
|
||||
var garage_device = TestGarageDevice()
|
||||
var garage = matter.Plugin_GarageDoor(garage_device, 1, {"shutter": 0})
|
||||
garage.update_shadow()
|
||||
garage.shadow_signature = true
|
||||
garage_tasmota.locked = true
|
||||
ctx = matter.Path(1, 0x0104, nil)
|
||||
ctx.command = 0
|
||||
assert(garage.invoke_request(nil, nil, ctx) == nil)
|
||||
assert(ctx.status == 0xCB)
|
||||
assert(garage.shadow_signature)
|
||||
assert(garage_tasmota.direction == 1)
|
||||
|
||||
var move_to = TLV.Matter_TLV_struct()
|
||||
move_to.add_TLV(0, 0x04, garage.TP_CLOSE)
|
||||
ctx = matter.Path(1, 0x0104, nil)
|
||||
ctx.command = 1
|
||||
assert(garage.invoke_request(nil, move_to, ctx) == nil)
|
||||
assert(ctx.status == 0xCB)
|
||||
assert(garage.shadow_signature)
|
||||
|
||||
garage_tasmota.locked = false
|
||||
garage_tasmota.fail = true
|
||||
ctx = matter.Path(1, 0x0104, nil)
|
||||
ctx.command = 0
|
||||
assert(garage.invoke_request(nil, nil, ctx) == nil)
|
||||
assert(ctx.status == 0x01)
|
||||
assert(garage.shadow_signature)
|
||||
|
||||
garage_tasmota.fail = false
|
||||
ctx = matter.Path(1, 0x0104, nil)
|
||||
ctx.command = 0
|
||||
assert(garage.invoke_request(nil, nil, ctx) == true)
|
||||
assert(ctx.status == nil)
|
||||
assert(!garage.shadow_signature)
|
||||
assert(garage_tasmota.direction == 0)
|
||||
|
||||
# Stop on an idle door returns `{"ShutterStop":"Done"}` and is SUCCESS. It
|
||||
# preserves OpenedAtSignature because no movement or target changed.
|
||||
garage_tasmota.position = 100
|
||||
garage_tasmota.target = 100
|
||||
garage.set_signature(true)
|
||||
garage.update_shadow()
|
||||
assert(garage.current_position_enum() == garage.CP_SIGNATURE)
|
||||
ctx = matter.Path(1, 0x0104, nil)
|
||||
ctx.command = 0
|
||||
assert(garage.invoke_request(nil, nil, ctx) == true)
|
||||
assert(ctx.status == nil)
|
||||
assert(garage.shadow_signature)
|
||||
assert(garage.current_position_enum() == garage.CP_SIGNATURE)
|
||||
assert(garage.target_position_enum() == garage.TP_SIGNATURE)
|
||||
|
||||
# ShutterInvert can change at runtime. The new inversion and the matching
|
||||
# Tasmota position must be applied atomically, without reporting open as secure.
|
||||
garage.set_signature(false)
|
||||
assert(garage.current_position_enum() == garage.CP_OPENED)
|
||||
garage_tasmota.inverted = true
|
||||
garage_tasmota.position = 0
|
||||
garage_tasmota.target = 0
|
||||
garage.update_shadow()
|
||||
assert(garage.shadow_shutter_inverted == 1)
|
||||
assert(garage.current_position_enum() == garage.CP_OPENED)
|
||||
ctx = matter.Path(1, 0x0104, 3)
|
||||
assert(garage.read_attribute(nil, ctx, TLV.Matter_TLV_item()).findsubval(3) == false)
|
||||
|
||||
# A malformed scalar CommandFields value is INVALID_COMMAND, not an uncaught
|
||||
# attribute_error while looking for fields on a non-structure TLV item.
|
||||
var malformed_move_to = TLV.Matter_TLV_item().set(0x06, garage.TP_CLOSE)
|
||||
ctx = matter.Path(1, 0x0104, nil)
|
||||
ctx.command = 1
|
||||
assert(garage.invoke_request(nil, malformed_move_to, ctx) == nil)
|
||||
assert(ctx.status == 0x85)
|
||||
print(" garage shutter command rejection is propagated to Matter: OK")
|
||||
tasmota = test_tasmota
|
||||
|
||||
# Virtual garage doors never call the Tasmota shutter backend. Matter commands
|
||||
# complete locally, while MtrUpdate can model an externally driven movement.
|
||||
class TestVirtualGarageTasmota
|
||||
def cmd(command, mute) assert(false, "virtual garage called tasmota.cmd") end
|
||||
def loglevel(level) return false end
|
||||
end
|
||||
matter.publish_command = def (prefix, endpoint, name, payload) end
|
||||
tasmota = TestVirtualGarageTasmota()
|
||||
var virtual_garage = matter.Plugin_Virt_GarageDoor(garage_device, 2, {})
|
||||
assert(virtual_garage.VIRTUAL)
|
||||
assert(virtual_garage.UPDATE_COMMANDS.find("ShutterPos") != nil)
|
||||
assert(virtual_garage.UPDATE_COMMANDS.find("ShutterTarget") != nil)
|
||||
assert(virtual_garage.UPDATE_COMMANDS.find("ShutterDirection") != nil)
|
||||
assert(virtual_garage.shadow_shutter_inverted == 0)
|
||||
assert(virtual_garage.current_position_enum() == virtual_garage.CP_CLOSED)
|
||||
assert(virtual_garage.target_position_enum() == virtual_garage.TP_CLOSE)
|
||||
assert(!virtual_garage.is_moving())
|
||||
|
||||
garage_device.events.published.clear()
|
||||
move_to = TLV.Matter_TLV_struct()
|
||||
move_to.add_TLV(0, 0x04, virtual_garage.TP_OPEN)
|
||||
ctx = matter.Path(2, 0x0104, nil)
|
||||
ctx.command = 1
|
||||
assert(virtual_garage.invoke_request(nil, move_to, ctx) == true)
|
||||
assert(virtual_garage.current_position_enum() == virtual_garage.CP_OPENED)
|
||||
assert(virtual_garage.target_position_enum() == virtual_garage.TP_OPEN)
|
||||
assert(!virtual_garage.is_moving())
|
||||
assert(garage_device.events.published.find(0x01) != nil) # MovementCompleted
|
||||
assert(garage_device.events.published.find(0x03) != nil) # SecureStateChanged
|
||||
assert(string.find(virtual_garage.state_json(), '"ShutterDirection":0') != nil)
|
||||
|
||||
move_to = TLV.Matter_TLV_struct()
|
||||
move_to.add_TLV(0, 0x04, virtual_garage.TP_SIGNATURE)
|
||||
ctx = matter.Path(2, 0x0104, nil)
|
||||
ctx.command = 1
|
||||
assert(virtual_garage.invoke_request(nil, move_to, ctx) == true)
|
||||
assert(virtual_garage.current_position_enum() == virtual_garage.CP_SIGNATURE)
|
||||
assert(virtual_garage.target_position_enum() == virtual_garage.TP_SIGNATURE)
|
||||
|
||||
virtual_garage.update_virtual({
|
||||
"ShutterPos": 50,
|
||||
"ShutterTarget": 0,
|
||||
"ShutterDirection": -3
|
||||
})
|
||||
assert(virtual_garage.current_position_enum() == virtual_garage.CP_PARTIAL)
|
||||
assert(virtual_garage.target_position_enum() == virtual_garage.TP_CLOSE)
|
||||
assert(virtual_garage.shadow_shutter_direction == -1)
|
||||
assert(virtual_garage.is_moving())
|
||||
|
||||
ctx = matter.Path(2, 0x0104, nil)
|
||||
ctx.command = 0
|
||||
assert(virtual_garage.invoke_request(nil, nil, ctx) == true)
|
||||
assert(!virtual_garage.is_moving())
|
||||
assert(virtual_garage.shadow_shutter_target == 50)
|
||||
assert(virtual_garage.target_position_enum() == nil)
|
||||
|
||||
virtual_garage.update_virtual({
|
||||
"ShutterPos": -10,
|
||||
"ShutterTarget": 120,
|
||||
"ShutterDirection": 0
|
||||
})
|
||||
assert(virtual_garage.shadow_shutter_pos == 0)
|
||||
assert(virtual_garage.shadow_shutter_target == 100)
|
||||
assert(virtual_garage.current_position_enum() == virtual_garage.CP_CLOSED)
|
||||
assert(virtual_garage.target_position_enum() == virtual_garage.TP_OPEN)
|
||||
print(" virtual garage door commands and updates: OK")
|
||||
tasmota = test_tasmota
|
||||
|
||||
# ConfigurationVersion changes with endpoint composition and is persisted.
|
||||
var config_device = TestConfigurationDevice()
|
||||
config_device.signal_endpoints_changed()
|
||||
|
||||
Reference in new issue
Block a user