diff --git a/lib/libesp32/berry_matter/src/be_matter_module.c b/lib/libesp32/berry_matter/src/be_matter_module.c index 65917083e..255c2dc7b 100644 --- a/lib/libesp32/berry_matter/src/be_matter_module.c +++ b/lib/libesp32/berry_matter/src/be_matter_module.c @@ -244,8 +244,11 @@ extern const bclass be_class_Matter_TLV; // need to declare it upfront because #include "solidify/solidified_Matter_Plugin_3_Sensor_Rain.h" #include "solidify/solidified_Matter_Plugin_3_Sensor_Waterleak.h" #include "solidify/solidified_Matter_Plugin_2_Fan.h" +#include "solidify/solidified_Matter_Plugin_2_Thermostat.h" #include "solidify/solidified_Matter_Plugin_2_Sensor_GenericSwitch_Btn.h" #include "solidify/solidified_Matter_Plugin_9_Virt_Fan.h" +#include "solidify/solidified_Matter_Plugin_9_Virt_HVAC.h" +#include "solidify/solidified_Matter_Plugin_9_Virt_HVAC_Option.h" #include "solidify/solidified_Matter_Plugin_9_Virt_Sensor_Contact.h" #include "solidify/solidified_Matter_Plugin_9_Virt_Sensor_Occupancy.h" #include "solidify/solidified_Matter_Plugin_9_Virt_Sensor_Rain.h" 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 3433a1497..4e241a0c5 100644 --- a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_0.be +++ b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_0.be @@ -95,6 +95,7 @@ class Matter_Plugin static var VIRTUAL = false # set to true only for virtual devices static var BRIDGE = false # set to true only for bridged devices (ESP8266 or OpenBK) static var ZIGBEE = false # set to true only when mapped to a zigbee device + static var AGGREGATE = true # list this endpoint under the Aggregator PartsList var update_next # next timestamp for update # Configuration of the plugin: clusters and type static var CLUSTERS = matter.consolidate_clusters(_class, { @@ -113,6 +114,7 @@ class Matter_Plugin 0x0046: 0x00, # ICD Management: 0x00 = no optional features (base SIT mode, no CIP/UAT/LITS) 0x0062: 0x01, # Scenes Management: SceneNames (bit 0) 0x0102: 1 + 4, # Window Covering: Lift (bit 0) + PA_LF (bit 2) + 0x0201: 0x23, # Thermostat: HEAT + COOL + AUTO 0x0202: 2, # Fan Control: Auto (bit 1) } # `CLUSTER_REVISIONS` contains revision numbers for each cluster, or `1` if not present diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_1_Aggregator.be b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_1_Aggregator.be index ac93bd9b5..86fd439c6 100644 --- a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_1_Aggregator.be +++ b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_1_Aggregator.be @@ -92,10 +92,10 @@ class Matter_Plugin_Aggregator : Matter_Plugin # overwrite PartsList elif attribute == 0x0003 # ---------- PartsList / list[endpoint-no]---------- var pl = TLV.Matter_TLV_array() - var eps = self.device.get_active_endpoints(true) - for ep: eps - if ep != 0x0001 #-matter.AGGREGATOR_ENDPOINT-# - pl.add_TLV(nil, 0x05 #-TLV.U2-#, ep) # add each endpoint + for plugin: self.device.plugins + var ep = plugin.get_endpoint() + if ep != 0x0001 #-matter.AGGREGATOR_ENDPOINT-# && ep != 0 && plugin.AGGREGATE + pl.add_TLV(nil, 0x05 #-TLV.U2-#, ep) # add each top-level endpoint end end return pl diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_2_Thermostat.be b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_2_Thermostat.be new file mode 100644 index 000000000..6920d3e9a --- /dev/null +++ b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_2_Thermostat.be @@ -0,0 +1,242 @@ +# +# Matter_Plugin_2_Thermostat.be - implements a generic Thermostat +# +# Copyright (C) 2026 Stephan Hadinger & Theo Arends +# +# 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.4.1 Device Specification - Thermostat (0x0301) +################################################################################# +# CLUSTERS (Server): +# - 0x0201: Thermostat +# +# MVP: +# - LocalTemperature, OccupiedCoolingSetpoint, OccupiedHeatingSetpoint +# - ControlSequenceOfOperation, SystemMode, ThermostatRunningState +# - Local shadow state only; subclasses can publish native commands +################################################################################# + +import matter + +#@ solidify:Matter_Plugin_Thermostat,weak + +class Matter_Plugin_Thermostat : Matter_Plugin_Device + static var TYPE = "thermostat" + static var DISPLAY_NAME = "Thermostat" + static var CLUSTERS = matter.consolidate_clusters(_class, { + 0x0201: [0,0x11,0x12,0x1B,0x1C,0x29], # Thermostat + }) + static var UPDATE_COMMANDS = matter.UC_LIST(_class, "SystemMode", "Temp", "CoolSetpoint", "HeatSetpoint") + static var TYPES = { 0x0301: 4 } # Thermostat - Matter 1.4.1 Device Library Rev 4 + + var shadow_local_temperature + var shadow_cooling_setpoint + var shadow_heating_setpoint + var shadow_system_mode + var shadow_running_state + var min_temp + var max_temp + + ############################################################# + # Thermostat SystemMode: + # 0: Off + # 1: Auto + # 3: Cool + # 4: Heat + # 7: Fan only + # 8: Dry + + def init(device, endpoint, config) + super(self).init(device, endpoint, config) + if self.min_temp == nil self.min_temp = 1600 end + if self.max_temp == nil self.max_temp = 3000 end + self.shadow_local_temperature = config.find("local_temp", nil) + if self.shadow_local_temperature != nil + self.shadow_local_temperature = int(self.shadow_local_temperature) * 100 + end + self.shadow_cooling_setpoint = self.clamp_temp(int(config.find("cool_temp", 2400))) + self.shadow_heating_setpoint = self.clamp_temp(int(config.find("heat_temp", 2000))) + self.shadow_system_mode = int(config.find("mode", 0)) + self.shadow_running_state = self.compute_running_state() + end + + def parse_configuration(config) + super(self).parse_configuration(config) + self.min_temp = int(config.find("min_temp", 16)) * 100 + self.max_temp = int(config.find("max_temp", 30)) * 100 + end + + def clamp_temp(temp) + temp = int(temp) + if temp < self.min_temp temp = self.min_temp end + if temp > self.max_temp temp = self.max_temp end + return temp + end + + def active_setpoint() + if self.shadow_system_mode == 4 + return self.shadow_heating_setpoint + else + return self.shadow_cooling_setpoint + end + end + + def compute_running_state() + if self.shadow_system_mode == 3 + return 0x0002 # Cool Stage 1 + elif self.shadow_system_mode == 4 + return 0x0001 # Heat Stage 1 + elif self.shadow_system_mode == 7 + return 0x0004 # Fan + elif self.shadow_system_mode == 8 + return 0x0004 # Dry: closest exposed state is fan + else + return 0 + end + end + + def thermostat_feature_map() + return 0x23 # HEAT + COOL + AUTO + end + + def set_system_mode(mode) + mode = int(mode) + if mode != 0 && mode != 1 && mode != 3 && mode != 4 && mode != 7 && mode != 8 + return false + end + if mode != self.shadow_system_mode + self.shadow_system_mode = mode + self.attribute_updated(0x0201, 0x001C) + var running_state = self.compute_running_state() + if running_state != self.shadow_running_state + self.shadow_running_state = running_state + self.attribute_updated(0x0201, 0x0029) + end + end + return true + end + + def set_cooling_setpoint(temp) + temp = self.clamp_temp(temp) + if temp != self.shadow_cooling_setpoint + self.shadow_cooling_setpoint = temp + self.attribute_updated(0x0201, 0x0011) + end + end + + def set_heating_setpoint(temp) + temp = self.clamp_temp(temp) + if temp != self.shadow_heating_setpoint + self.shadow_heating_setpoint = temp + self.attribute_updated(0x0201, 0x0012) + end + end + + def read_attribute(session, ctx, tlv_solo) + var cluster = ctx.cluster + var attribute = ctx.attribute + + if cluster == 0x0201 # Thermostat + self.update_shadow_lazy() + if attribute == 0xFFFC # FeatureMap / map32 + return tlv_solo.set(0x06 #-TLV.U4-#, self.thermostat_feature_map()) + elif attribute == 0x0000 # LocalTemperature / int16 + if self.shadow_local_temperature == nil + return tlv_solo.set(0x14 #-TLV.NULL-#, nil) + else + return tlv_solo.set(0x01 #-TLV.I2-#, self.shadow_local_temperature) + end + elif attribute == 0x0011 # OccupiedCoolingSetpoint / int16 + return tlv_solo.set(0x01 #-TLV.I2-#, self.shadow_cooling_setpoint) + elif attribute == 0x0012 # OccupiedHeatingSetpoint / int16 + return tlv_solo.set(0x01 #-TLV.I2-#, self.shadow_heating_setpoint) + elif attribute == 0x001B # ControlSequenceOfOperation / enum8 + return tlv_solo.set(0x04 #-TLV.U1-#, 4) # Cooling and heating + elif attribute == 0x001C # SystemMode / enum8 + return tlv_solo.set(0x04 #-TLV.U1-#, self.shadow_system_mode) + elif attribute == 0x0029 # ThermostatRunningState / bitmap16 + return tlv_solo.set(0x05 #-TLV.U2-#, self.shadow_running_state) + end + end + return super(self).read_attribute(session, ctx, tlv_solo) + end + + def write_attribute(session, ctx, write_data) + var cluster = ctx.cluster + var attribute = ctx.attribute + + if cluster == 0x0201 # Thermostat + self.update_shadow_lazy() + if attribute == 0x0011 # OccupiedCoolingSetpoint + if type(write_data) == 'int' + self.set_cooling_setpoint(write_data) + self.thermostat_state_changed() + self.publish_command('CoolSetpoint', self.shadow_cooling_setpoint, 'Temp', self.active_setpoint(), nil, nil) + return true + end + elif attribute == 0x0012 # OccupiedHeatingSetpoint + if type(write_data) == 'int' + self.set_heating_setpoint(write_data) + self.thermostat_state_changed() + self.publish_command('HeatSetpoint', self.shadow_heating_setpoint, 'Temp', self.active_setpoint(), nil, nil) + return true + end + elif attribute == 0x001C # SystemMode + if type(write_data) == 'int' && self.set_system_mode(write_data) + self.thermostat_state_changed() + self.publish_command('SystemMode', self.shadow_system_mode, 'Temp', self.active_setpoint(), nil, nil) + return true + end + end + ctx.status = 0x87 #-matter.CONSTRAINT_ERROR-# + return false + end + return nil + end + + def thermostat_state_changed() + end + + def update_virtual(payload) + var val_mode = payload.find("SystemMode") + if val_mode != nil + self.set_system_mode(int(val_mode)) + end + var val_cool = payload.find("CoolSetpoint") + if val_cool != nil + self.set_cooling_setpoint(int(val_cool)) + end + var val_heat = payload.find("HeatSetpoint") + if val_heat != nil + self.set_heating_setpoint(int(val_heat)) + end + var val_temp = payload.find("Temp") + if val_temp != nil + self.set_cooling_setpoint(int(val_temp)) + self.set_heating_setpoint(int(val_temp)) + end + var val_local = payload.find("LocalTemperature") + if val_local != nil + val_local = int(val_local) + if val_local != self.shadow_local_temperature + self.shadow_local_temperature = val_local + self.attribute_updated(0x0201, 0x0000) + end + end + end + +end +matter.Plugin_Thermostat = Matter_Plugin_Thermostat diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_9_Virt_HVAC.be b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_9_Virt_HVAC.be new file mode 100644 index 000000000..34bfb1299 --- /dev/null +++ b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_9_Virt_HVAC.be @@ -0,0 +1,187 @@ +# +# Matter_Plugin_9_Virt_HVAC.be - implements a virtual IR HVAC thermostat +# +# Copyright (C) 2026 Stephan Hadinger & Theo Arends +# +# 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.4.1 Virtual Variant - HVAC / IRHVAC Thermostat +################################################################################# +# TYPE: "v_hvac" | VIRTUAL: true +# Maps Matter Thermostat writes to a complete Tasmota IRHVAC JSON command. +################################################################################# + +import matter + +#@ solidify:Matter_Plugin_Virt_HVAC,weak + +class Matter_Plugin_Virt_HVAC : Matter_Plugin_Thermostat + static var TYPE = "v_hvac" + static var DISPLAY_NAME = "v.HVAC" + static var SCHEMA = "vendor|l:Vendor|t:t|h:IRHVAC vendor/protocol, for example MITSUBISHI_AC or DAIKIN|r:1" + static var SCHEMA2 = "model|l:Model|t:i|h:IRHVAC model, usually -1|d:-1" + static var SCHEMA3 = "min_temp|l:Min temp|t:i|h:Minimum setpoint in Celsius|d:16" + static var SCHEMA4 = "max_temp|l:Max temp|t:i|h:Maximum setpoint in Celsius|d:30" + static var SCHEMA5 = "fan|l:Fan|t:s|d:Auto|o:Auto:Auto,Min:Min,Low:Low,Medium:Medium,High:High,Max:Max" + static var SCHEMA6 = "temp_filter|l:Temp sensor|t:t|h:Tasmota sensor filter, for example SHT3X#Temperature or DS18B20#Temperature" + static var UPDATE_TIME = 5000 + static var VIRTUAL = true + + var ir_vendor + var ir_model + var ir_fan + var local_temp_filter + var local_temp_matcher + + def init(device, endpoint, config) + super(self).init(device, endpoint, config) + if self.local_temp_matcher + device.add_read_sensors_schedule(self.UPDATE_TIME) + end + end + + def parse_configuration(config) + super(self).parse_configuration(config) + var vendor = config.find("vendor", nil) + self.ir_vendor = vendor == nil ? "" : str(vendor) + self.ir_model = config.find("model", -1) + self.ir_fan = config.find("fan", "Auto") + self.local_temp_filter = config.find("temp_filter") + if self.local_temp_filter + self.local_temp_matcher = tasmota.Rule_Matcher.parse(self.local_temp_filter) + end + if self.ir_vendor == "" + log("MTR: v.HVAC endpoint " + str(self.endpoint) + " missing IRHVAC vendor; commands will be ignored", 2) + end + end + + def thermostat_state_changed() + self.send_irhvac() + end + + def mode_to_irhvac() + if self.shadow_system_mode == 4 + return "Heat" + elif self.shadow_system_mode == 1 + return "Auto" + elif self.shadow_system_mode == 7 + return "Fan" + elif self.shadow_system_mode == 8 + return "Dry" + else + return "Cool" + end + end + + def thermostat_feature_map() + var feature_map = super(self).thermostat_feature_map() + if self.local_temp_matcher == nil + feature_map = feature_map | 0x40 # LTNE: LocalTemperatureNotExposed + end + return feature_map + end + + def send_irhvac() + if self.ir_vendor == nil || self.ir_vendor == "" + return nil + end + + import json + var power = self.shadow_system_mode == 0 ? "Off" : "On" + var temp = int(self.active_setpoint() / 100) + var payload = { + "Vendor": self.ir_vendor, + "Model": self.ir_model, + "Power": power, + "Mode": self.mode_to_irhvac(), + "Celsius": "On", + "Temp": temp, + "FanSpeed": self.ir_fan, + "SwingV": "Off", + "SwingH": "Off", + "Quiet": self.option_state("Quiet"), + "Turbo": self.option_state("Turbo"), + "Econo": self.option_state("Econo"), + "Light": self.option_state("Light"), + "Filter": self.option_state("Filter"), + "Clean": self.option_state("Clean"), + "Beep": self.option_state("Beep"), + "iFeel": self.option_state("iFeel"), + "Sleep": -1 + } + var cmd = "IRHVAC " + json.dump(payload) + log("MTR: v.HVAC " + cmd, 2) + var resp = tasmota.cmd(cmd, true) + if isinstance(resp, map) + var irhvac_resp = resp.find("IRHVAC", nil) + if type(irhvac_resp) == 'string' + log("MTR: v.HVAC endpoint " + str(self.endpoint) + " IRHVAC rejected vendor '" + self.ir_vendor + "': " + irhvac_resp, 2) + end + end + return resp + end + + def option_state(option_name) + for pl: self.device.plugins + if pl.TYPE == "v_hvac_option" && pl.hvac_ep == self.endpoint && pl.option_name == option_name + return pl.shadow_onoff ? "On" : "Off" + end + end + return "Off" + end + + def read_attribute(session, ctx, tlv_solo) + if ctx.cluster == 0x001D && ctx.attribute == 0x0003 + var TLV = matter.TLV + var parts = TLV.Matter_TLV_array() + for pl: self.device.plugins + if pl.TYPE == "v_hvac_option" && pl.hvac_ep == self.endpoint + parts.add_TLV(nil, 0x05 #-TLV.U2-#, pl.endpoint) + end + end + return parts + end + return super(self).read_attribute(session, ctx, tlv_solo) + end + + def update_virtual(payload) + super(self).update_virtual(payload) + self.send_irhvac() + end + + def parse_sensors(payload) + if self.local_temp_matcher + var val = self.local_temp_matcher.match(payload) + if isinstance(val, map) + val = val.find("Temperature") + end + if val != nil + val = real(val) + if tasmota.get_option(8) == 1 + val = (val - 32) / 1.8 + end + val = int(val * 100) + if val != self.shadow_local_temperature + self.shadow_local_temperature = val + self.attribute_updated(0x0201, 0x0000) + end + end + end + super(self).parse_sensors(payload) + end + +end +matter.Plugin_Virt_HVAC = Matter_Plugin_Virt_HVAC diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_9_Virt_HVAC_Option.be b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_9_Virt_HVAC_Option.be new file mode 100644 index 000000000..c58aae6aa --- /dev/null +++ b/lib/libesp32/berry_matter/src/embedded/Matter_Plugin_9_Virt_HVAC_Option.be @@ -0,0 +1,66 @@ +# +# Matter_Plugin_9_Virt_HVAC_Option.be - virtual switch bound to a v.HVAC IRHVAC option +# +# Copyright (C) 2026 Stephan Hadinger & Theo Arends +# +# 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 . +# + +################################################################################# +# Virtual On/Off switch that toggles an extra IRHVAC option on a v.HVAC endpoint. +################################################################################# + +import matter + +#@ solidify:Matter_Plugin_Virt_HVAC_Option,weak + +class Matter_Plugin_Virt_HVAC_Option : Matter_Plugin_OnOff + static var TYPE = "v_hvac_option" + static var DISPLAY_NAME = "v.HVAC option" + static var SCHEMA = "hvac_ep|l:HVAC endpoint|t:i|h:Endpoint number of the v.HVAC device, usually 2|r:1" + static var SCHEMA2 = "option|l:Option|t:s|d:Econo|o:Econo:Econo,Quiet:Quiet,Turbo:Turbo,Light:Light,Filter:Filter,Clean:Clean,Beep:Beep,iFeel:iFeel" + static var VIRTUAL = true + static var AGGREGATE = false + + var hvac_ep + var option_name + + def parse_configuration(config) + super(self).parse_configuration(config) + self.hvac_ep = int(config.find("hvac_ep", 0)) + self.option_name = config.find("option", "Econo") + end + + def set_onoff(pow) + var old_onoff = self.shadow_onoff + super(self).set_onoff(pow) + if old_onoff != self.shadow_onoff + self.trigger_hvac() + end + end + + def trigger_hvac() + if self.hvac_ep <= 0 + return nil + end + var hvac = self.device.find_plugin_by_endpoint(self.hvac_ep) + if hvac != nil && hvac.TYPE == "v_hvac" + return hvac.send_irhvac() + end + log("MTR: v.HVAC option could not find v.HVAC endpoint " + str(self.hvac_ep), 2) + return nil + end + +end +matter.Plugin_Virt_HVAC_Option = Matter_Plugin_Virt_HVAC_Option diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_UI.be b/lib/libesp32/berry_matter/src/embedded/Matter_UI.be index 1d2c0f51d..3fe3f5047 100644 --- a/lib/libesp32/berry_matter/src/embedded/Matter_UI.be +++ b/lib/libesp32/berry_matter/src/embedded/Matter_UI.be @@ -192,7 +192,7 @@ class Matter_UI "|airquality" static var _CLASSES_TYPES_VIRTUAL = "-virtual|v_relay|v_light0|v_light1|v_light2|v_light3" - "|v_fan" + "|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" static var _CLASSES_TYPES2= "|http_relay|http_light0|http_light1|http_light2|http_light3" diff --git a/lib/libesp32/berry_matter/src/embedded/Matter_zz_Device.be b/lib/libesp32/berry_matter/src/embedded/Matter_zz_Device.be index ba9c9e97c..d0541c5f4 100644 --- a/lib/libesp32/berry_matter/src/embedded/Matter_zz_Device.be +++ b/lib/libesp32/berry_matter/src/embedded/Matter_zz_Device.be @@ -730,7 +730,10 @@ class Matter_Device def signal_endpoints_changed() # mark parts lists as changed self.attribute_updated(0x0000, 0x001D, 0x0003, false) - self.attribute_updated(0x0001 #-matter.AGGREGATOR_ENDPOINT-#, 0x001D, 0x0003, false) + var eps = self.get_active_endpoints(true) + for ep: eps + self.attribute_updated(ep, 0x001D, 0x0003, false) + end end #############################################################