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:
s-hadinger authored and GitHub committed 2026-09-29 22:08:42 +02:00
1 parent dfa00bc969
commit ddc0ae61d9
12 files changed
+1032 -49

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -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("&#x1F6AA; %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"
+233 -2
View File
@@ -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()