Add Matter virtual IR HVAC thermostat support (#24821)

* Add Matter virtual IR HVAC thermostat support

Introduce a generic Matter thermostat plugin and a virtual HVAC plugin that maps thermostat state changes to Tasmota IRHVAC commands.

Add virtual HVAC option switches for IRHVAC boolean options such as Econo, Quiet, Turbo, Light, Filter, Clean, Beep, and iFeel. These option endpoints are attached to the HVAC endpoint PartsList instead of being listed as top-level aggregator parts.

Expose the new virtual device types in the Matter UI and refresh endpoint PartsList attributes when endpoint composition changes.

* Warn on invalid Matter v.HVAC vendor

* Fix regression

* Matter virtual IR HVAC thermostat support

* Revert

---------

Co-authored-by: Jean-Laurent Girod <macjl@users.noreply.github.com>
Co-authored-by: s-hadinger <49731213+s-hadinger@users.noreply.github.com>
This commit is contained in:
Jean-Laurent Girod
2026-07-07 20:00:24 +02:00
committed by GitHub
co-authored by Jean-Laurent Girod s-hadinger
parent ff1548f686
commit 5acc4a368b
8 changed files with 509 additions and 6 deletions
@@ -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"
@@ -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
@@ -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
@@ -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 <http://www.gnu.org/licenses/>.
#
#################################################################################
# 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
@@ -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 <http://www.gnu.org/licenses/>.
#
#################################################################################
# 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
@@ -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 <http://www.gnu.org/licenses/>.
#
#################################################################################
# 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
@@ -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"
@@ -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
#############################################################