diff --git a/CHANGELOG.md b/CHANGELOG.md index e068f675a..f64acb749 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 65f8f71d4..909a5618d 100755 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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 diff --git a/lib/libesp32/berry_matter/MATTER_1.4.1_TO_1.6.0_CHANGELOG.md b/lib/libesp32/berry_matter/MATTER_1.4.1_TO_1.6.0_CHANGELOG.md index e291b401e..a258fa3ca 100644 --- a/lib/libesp32/berry_matter/MATTER_1.4.1_TO_1.6.0_CHANGELOG.md +++ b/lib/libesp32/berry_matter/MATTER_1.4.1_TO_1.6.0_CHANGELOG.md @@ -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` 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 diff --git a/lib/libesp32/berry_matter/MATTER_1.6.0_DETAILED_GAP_ANALYSIS.md b/lib/libesp32/berry_matter/MATTER_1.6.0_DETAILED_GAP_ANALYSIS.md index e313af6ac..27bb3cb30 100644 --- a/lib/libesp32/berry_matter/MATTER_1.6.0_DETAILED_GAP_ANALYSIS.md +++ b/lib/libesp32/berry_matter/MATTER_1.6.0_DETAILED_GAP_ANALYSIS.md @@ -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` / + 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` / 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 | diff --git a/lib/libesp32/berry_matter/src/be_matter_module.c b/lib/libesp32/berry_matter/src/be_matter_module.c index d9333fb22..d7dac77b6 100644 --- a/lib/libesp32/berry_matter/src/be_matter_module.c +++ b/lib/libesp32/berry_matter/src/be_matter_module.c @@ -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" diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_IM.be b/lib/libesp32/berry_matter/src/embedded/Matter_IM.be index 9d0a54ab9..50b7e0c21 100644 --- a/lib/libesp32/berry_matter/src/embedded/Matter_IM.be +++ b/lib/libesp32/berry_matter/src/embedded/Matter_IM.be @@ -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 # diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_0.be b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_0.be index 2354780f5..1b998fe37 100644 --- a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_0.be +++ b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_0.be @@ -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 diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_1_Device.be b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_1_Device.be index 5fcb02f87..0ca63ba0d 100644 --- a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_1_Device.be +++ b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_1_Device.be @@ -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 diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_2_GarageDoor.be b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_2_GarageDoor.be new file mode 100644 index 000000000..04910577a --- /dev/null +++ b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_2_GarageDoor.be @@ -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 . +# + +################################################################################# +# 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`; honors +# ShutterInvert (right-most char of `Status 13` SHT.Opt) +# - MoveTo FullyOpen / Signature -> ShutterOpen (Signature = fully open) +# - MoveTo FullyClosed -> ShutterClose +# - Stop -> ShutterStop +# - 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 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":"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 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` 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 diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_9_Virt_GarageDoor.be b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_9_Virt_GarageDoor.be new file mode 100644 index 000000000..b56662a7c --- /dev/null +++ b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_9_Virt_GarageDoor.be @@ -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 . +# + +################################################################################# +# 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 diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_UI.be b/lib/libesp32/berry_matter/src/embedded/Matter_UI.be index 24514cbb4..bcac69aea 100644 --- a/lib/libesp32/berry_matter/src/embedded/Matter_UI.be +++ b/lib/libesp32/berry_matter/src/embedded/Matter_UI.be @@ -187,12 +187,12 @@ class Matter_UI "" 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" diff --git a/lib/libesp32/berry_matter/tests/run_tests.be b/lib/libesp32/berry_matter/tests/run_tests.be index 070e5cb9b..ed8adcba9 100644 --- a/lib/libesp32/berry_matter/tests/run_tests.be +++ b/lib/libesp32/berry_matter/tests/run_tests.be @@ -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()