diff --git a/drivers/SmartThings/matter-thermostat/profiles/air-purifier-modular.yml b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-modular.yml new file mode 100644 index 0000000000..1cc03aaaff --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-modular.yml @@ -0,0 +1,127 @@ +name: air-purifier-modular +components: + - id: main + capabilities: + - id: airPurifierFanMode + version: 1 + - id: fanSpeedPercent + version: 1 + - id: fanOscillationMode + version: 1 + optional: true + - id: windMode + version: 1 + optional: true + - id: thermostatHeatingSetpoint + version: 1 + optional: true + - id: thermostatCoolingSetpoint + version: 1 + optional: true + - id: thermostatMode + version: 1 + optional: true + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - idle + - cooling + - heating + optional: true + - id: temperatureMeasurement + version: 1 + optional: true + - id: relativeHumidityMeasurement + version: 1 + optional: true + - id: airQualityHealthConcern + version: 1 + optional: true + - id: carbonMonoxideMeasurement + version: 1 + optional: true + - id: carbonMonoxideHealthConcern + version: 1 + optional: true + - id: carbonDioxideMeasurement + version: 1 + optional: true + - id: carbonDioxideHealthConcern + version: 1 + optional: true + - id: nitrogenDioxideMeasurement + version: 1 + optional: true + - id: nitrogenDioxideHealthConcern + version: 1 + optional: true + - id: ozoneMeasurement + version: 1 + optional: true + - id: ozoneHealthConcern + version: 1 + optional: true + - id: formaldehydeMeasurement + version: 1 + optional: true + - id: formaldehydeHealthConcern + version: 1 + optional: true + - id: veryFineDustSensor + version: 1 + optional: true + - id: veryFineDustHealthConcern + version: 1 + optional: true + - id: fineDustHealthConcern + version: 1 + optional: true + - id: dustSensor + version: 1 + optional: true + - id: dustHealthConcern + version: 1 + optional: true + - id: radonMeasurement + version: 1 + optional: true + - id: radonHealthConcern + version: 1 + optional: true + - id: tvocMeasurement + version: 1 + optional: true + - id: tvocHealthConcern + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirPurifier + - id: activatedCarbonFilter + optional: true + capabilities: + - id: filterState + version: 1 + optional: true + - id: filterStatus + version: 1 + optional: true + categories: + - name: AirPurifier + - id: hepaFilter + optional: true + capabilities: + - id: filterState + version: 1 + optional: true + - id: filterStatus + version: 1 + optional: true + categories: + - name: AirPurifier diff --git a/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-modular.yml b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-modular.yml new file mode 100644 index 0000000000..940d864ad9 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/room-air-conditioner-modular.yml @@ -0,0 +1,38 @@ +name: room-air-conditioner-modular +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + optional: true + - id: thermostatMode + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + optional: true + - id: thermostatCoolingSetpoint + version: 1 + optional: true + - id: thermostatOperatingState + version: 1 + optional: true + - id: airConditionerFanMode + version: 1 + optional: true + - id: fanSpeedPercent + version: 1 + optional: true + - id: windMode + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: AirConditioner + diff --git a/drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml b/drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml new file mode 100644 index 0000000000..69972f1223 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml @@ -0,0 +1,35 @@ +name: thermostat-modular +components: + - id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + optional: true + - id: thermostatHeatingSetpoint + version: 1 + optional: true + - id: thermostatCoolingSetpoint + version: 1 + optional: true + - id: thermostatOperatingState + version: 1 + optional: true + - id: battery + version: 1 + optional: true + - id: batteryLevel + version: 1 + optional: true + - id: relativeHumidityMeasurement + version: 1 + optional: true + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/SmartThings/matter-thermostat/src/init.lua b/drivers/SmartThings/matter-thermostat/src/init.lua index f563303c73..717d5fabbb 100644 --- a/drivers/SmartThings/matter-thermostat/src/init.lua +++ b/drivers/SmartThings/matter-thermostat/src/init.lua @@ -21,6 +21,10 @@ local im = require "st.matter.interaction_model" local MatterDriver = require "st.matter.driver" local utils = require "st.utils" +local SUPPORTED_COMPONENT_CAPABILITIES = "__supported_component_capabilities" +-- declare match_profile function for use throughout file +local match_profile + -- Include driver-side definitions when lua libs api version is < 10 local version = require "version" if version.api < 10 then @@ -305,6 +309,25 @@ local subscribed_attributes = { }, } +local function supports_capability_by_id_modular(device, capability, component) + if not device:get_field(SUPPORTED_COMPONENT_CAPABILITIES) then + device.log.warn_with({hub_logs = true}, "Device has overriden supports_capability_by_id, but does not have supported capabilities set.") + return false + end + for _, component_capabilities in ipairs(device:get_field(SUPPORTED_COMPONENT_CAPABILITIES)) do + local comp_id = component_capabilities[1] + local capability_ids = component_capabilities[2] + if (component == nil) or (component == comp_id) then + for _, cap in ipairs(capability_ids) do + if cap == capability then + return true + end + end + end + end + return false +end + local function epoch_to_iso8601(time) return os.date("!%Y-%m-%dT%H:%M:%SZ", time) end @@ -462,6 +485,11 @@ local function schedule_polls_for_cumulative_energy_imported(device) end local function device_init(driver, device) + if device:get_field(SUPPORTED_COMPONENT_CAPABILITIES) then + -- assume that device is using a modular profile, override supports_capability_by_id + -- library function to utilize optional capabilities + device:extend_device("supports_capability_by_id", supports_capability_by_id_modular) + end device:subscribe() device:set_component_to_endpoint_fn(component_to_endpoint) device:set_endpoint_to_component_fn(endpoint_to_component) @@ -478,17 +506,15 @@ local function device_init(driver, device) end local function info_changed(driver, device, event, args) - -- Note this is needed because device:subscribe() does not recalculate - -- the subscribed attributes each time it is run, that only happens at init. - -- This will change in the 0.48.x release of the lua libs. - for cap_id, attributes in pairs(subscribed_attributes) do - if device:supports_capability_by_id(cap_id) then - for _, attr in ipairs(attributes) do - device:add_subscribed_attribute(attr) - end - end + if device:get_field(SUPPORTED_COMPONENT_CAPABILITIES) then + -- This indicates the device should be using a modular profile, so + -- re-up subscription with new capabilities using the modular supports_capability override + device:extend_device("supports_capability_by_id", supports_capability_by_id_modular) + end + + if device.profile.id ~= args.old_st_store.profile.id then + device:subscribe() end - device:subscribe() schedule_polls_for_cumulative_energy_imported(device) end @@ -506,7 +532,7 @@ local function get_endpoints_for_dt(device, device_type) return endpoints end -local function get_device_type(driver, device) +local function get_device_type(device) for _, ep in ipairs(device.endpoints) do if ep.device_types ~= nil then for _, dt in ipairs(ep.device_types) do @@ -571,6 +597,27 @@ local function create_level_measurement_profile(device) return meas_name, level_name end +local function supported_level_measurements(device) + local measurement_caps, level_caps = {}, {} + for _, details in ipairs(AIR_QUALITY_MAP) do + local cap_id = details[1] + local cluster = details[3] + -- capability describes either a HealthConcern or Measurement/Sensor + if (cap_id:match("HealthConcern$")) then + local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.LEVEL_INDICATION }) + if #attr_eps > 0 then + table.insert(level_caps, cap_id) + end + elseif (cap_id:match("Measurement$") or cap_id:match("Sensor$")) then + local attr_eps = embedded_cluster_utils.get_endpoints(device, cluster.ID, { feature_bitmap = cluster.types.Feature.NUMERIC_MEASUREMENT }) + if #attr_eps > 0 then + table.insert(measurement_caps, cap_id) + end + end + end + return measurement_caps, level_caps +end + local function create_air_quality_sensor_profile(device) local aqs_eps = embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID) local profile_name = "" @@ -652,7 +699,7 @@ local function profiling_data_still_required(device) return false end -local function match_profile(driver, device) +local function match_profile_switch(driver, device) if profiling_data_still_required(device) then return end local running_state_supported = device:get_field(profiling_data.THERMOSTAT_RUNNING_STATE_SUPPORT) @@ -660,7 +707,7 @@ local function match_profile(driver, device) local thermostat_eps = device:get_endpoints(clusters.Thermostat.ID) local humidity_eps = device:get_endpoints(clusters.RelativeHumidityMeasurement.ID) - local device_type = get_device_type(driver, device) + local device_type = get_device_type(device) local profile_name if device_type == RAC_DEVICE_TYPE_ID then profile_name = "room-air-conditioner" @@ -789,10 +836,291 @@ local function match_profile(driver, device) end end +local function get_thermostat_optional_capabilities(device) + local heat_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.HEATING}) + local cool_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.COOLING}) + local running_state_supported = device:get_field(profiling_data.THERMOSTAT_RUNNING_STATE_SUPPORT) + + local supported_thermostat_capabilities = {} + + if #heat_eps > 0 then + table.insert(supported_thermostat_capabilities, capabilities.thermostatHeatingSetpoint.ID) + end + if #cool_eps > 0 then + table.insert(supported_thermostat_capabilities, capabilities.thermostatCoolingSetpoint.ID) + end + + if running_state_supported then + table.insert(supported_thermostat_capabilities, capabilities.thermostatOperatingState.ID) + end + + return supported_thermostat_capabilities +end + +local function get_air_quality_optional_capabilities(device) + local supported_air_quality_capabilities = {} + + local measurement_caps, level_caps = supported_level_measurements(device) + + for _, cap_id in ipairs(measurement_caps) do + table.insert(supported_air_quality_capabilities, cap_id) + end + + for _, cap_id in ipairs(level_caps) do + table.insert(supported_air_quality_capabilities, cap_id) + end + + return supported_air_quality_capabilities +end + +local function match_modular_profile_air_purifer(driver, device) + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + local hepa_filter_component_capabilities = {} + local ac_filter_component_capabilties = {} + local profile_name = "air-purifier-modular" + + local MAIN_COMPONENT_IDX = 1 + local CAPABILITIES_LIST_IDX = 2 + + local humidity_eps = device:get_endpoints(clusters.RelativeHumidityMeasurement.ID) + local temp_eps = embedded_cluster_utils.get_endpoints(device, clusters.TemperatureMeasurement.ID) + if #humidity_eps > 0 then + table.insert(main_component_capabilities, capabilities.relativeHumidityMeasurement.ID) + end + if #temp_eps > 0 then + table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + end + + local hepa_filter_eps = embedded_cluster_utils.get_endpoints(device, clusters.HepaFilterMonitoring.ID) + local ac_filter_eps = embedded_cluster_utils.get_endpoints(device, clusters.ActivatedCarbonFilterMonitoring.ID) + + if #hepa_filter_eps > 0 then + local filter_state_eps = embedded_cluster_utils.get_endpoints(device, clusters.HepaFilterMonitoring.ID, {feature_bitmap = clusters.HepaFilterMonitoring.types.Feature.CONDITION}) + if #filter_state_eps > 0 then + table.insert(hepa_filter_component_capabilities, capabilities.filterState.ID) + end + + table.insert(hepa_filter_component_capabilities, capabilities.filterStatus.ID) + end + if #ac_filter_eps > 0 then + local filter_state_eps = embedded_cluster_utils.get_endpoints(device, clusters.ActivatedCarbonFilterMonitoring.ID, {feature_bitmap = clusters.ActivatedCarbonFilterMonitoring.types.Feature.CONDITION}) + if #filter_state_eps > 0 then + table.insert(ac_filter_component_capabilties, capabilities.filterState.ID) + end + + table.insert(ac_filter_component_capabilties, capabilities.filterStatus.ID) + end + + -- determine fan capabilities, note that airPurifierFanMode is already mandatory + local rock_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.Feature.ROCKING}) + local wind_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.FanControlFeature.WIND}) + + if #rock_eps > 0 then + table.insert(main_component_capabilities, capabilities.fanOscillationMode.ID) + end + if #wind_eps > 0 then + table.insert(main_component_capabilities, capabilities.windMode.ID) + end + + local thermostat_eps = device:get_endpoints(clusters.Thermostat.ID) + + if #thermostat_eps > 0 then + -- thermostatMode and temperatureMeasurement should be expected if thermostat is present + table.insert(main_component_capabilities, capabilities.thermostatMode.ID) + + -- only add temperatureMeasurement if it is not already added via TemperatureMeasurement cluster support + if #temp_eps == 0 then + table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + end + local thermostat_capabilities = get_thermostat_optional_capabilities(device) + for _, capability_id in pairs(thermostat_capabilities) do + table.insert(main_component_capabilities, capability_id) + end + end + + local aqs_eps = embedded_cluster_utils.get_endpoints(device, clusters.AirQuality.ID) + if #aqs_eps > 0 then + table.insert(main_component_capabilities, capabilities.airQualityHealthConcern.ID) + end + + local supported_air_quality_capabilities = get_air_quality_optional_capabilities(device) + for _, capability_id in pairs(supported_air_quality_capabilities) do + table.insert(main_component_capabilities, capability_id) + end + + table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) + if #ac_filter_component_capabilties > 0 then + table.insert(optional_supported_component_capabilities, {"activatedCarbonFilter", ac_filter_component_capabilties}) + end + if #hepa_filter_component_capabilities > 0 then + table.insert(optional_supported_component_capabilities, {"hepaFilter", hepa_filter_component_capabilities}) + end + + device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities}) + + -- add mandatory capabilities for subscription + local total_supported_capabilities = optional_supported_component_capabilities + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.airPurifierFanMode.ID) + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.fanSpeedPercent.ID) + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.refresh.ID) + table.insert(total_supported_capabilities[MAIN_COMPONENT_IDX][CAPABILITIES_LIST_IDX], capabilities.firmwareUpdate.ID) + + device:set_field(SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) +end + +local function match_modular_profile_thermostat(driver, device) + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + local profile_name = "thermostat-modular" + + local humidity_eps = device:get_endpoints(clusters.RelativeHumidityMeasurement.ID) + if #humidity_eps > 0 then + table.insert(main_component_capabilities, capabilities.relativeHumidityMeasurement.ID) + end + + -- determine fan capabilities + local fan_eps = device:get_endpoints(clusters.FanControl.ID) + local rock_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.Feature.ROCKING}) + local wind_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.FanControlFeature.WIND}) + + if #fan_eps > 0 then + table.insert(main_component_capabilities, capabilities.thermostatFanMode.ID) + end + if #rock_eps > 0 then + table.insert(main_component_capabilities, capabilities.fanOscillationMode.ID) + end + if #wind_eps > 0 then + table.insert(main_component_capabilities, capabilities.windMode.ID) + end + + local thermostat_capabilities = get_thermostat_optional_capabilities(device) + for _, capability_id in pairs(thermostat_capabilities) do + table.insert(main_component_capabilities, capability_id) + end + + local battery_supported = device:get_field(profiling_data.BATTERY_SUPPORT) + if battery_supported == battery_support.BATTERY_LEVEL then + table.insert(main_component_capabilities, capabilities.batteryLevel.ID) + elseif battery_supported == battery_support.BATTERY_PERCENTAGE then + table.insert(main_component_capabilities, capabilities.battery.ID) + end + + table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) + device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities}) + + -- add mandatory capabilities for subscription + local total_supported_capabilities = optional_supported_component_capabilities + table.insert(main_component_capabilities, capabilities.thermostatMode.ID) + table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + table.insert(main_component_capabilities, capabilities.refresh.ID) + table.insert(main_component_capabilities, capabilities.firmwareUpdate.ID) + + device:set_field(SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) +end + +local function match_modular_profile_room_ac(driver, device) + local running_state_supported = device:get_field(profiling_data.THERMOSTAT_RUNNING_STATE_SUPPORT) + local humidity_eps = device:get_endpoints(clusters.RelativeHumidityMeasurement.ID) + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + local profile_name = "room-air-conditioner-modular" + + if #humidity_eps > 0 then + table.insert(main_component_capabilities, capabilities.relativeHumidityMeasurement.ID) + end + + -- determine fan capabilities + local fan_eps = device:get_endpoints(clusters.FanControl.ID) + local wind_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.FanControlFeature.WIND}) + -- Note: Room AC does not support the rocking feature of FanControl. + + if #fan_eps > 0 then + table.insert(main_component_capabilities, capabilities.airConditionerFanMode.ID) + table.insert(main_component_capabilities, capabilities.fanSpeedPercent.ID) + end + if #wind_eps > 0 then + table.insert(main_component_capabilities, capabilities.windMode.ID) + end + + local heat_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.HEATING}) + local cool_eps = device:get_endpoints(clusters.Thermostat.ID, {feature_bitmap = clusters.Thermostat.types.ThermostatFeature.COOLING}) + + if #heat_eps > 0 then + table.insert(main_component_capabilities, capabilities.thermostatHeatingSetpoint.ID) + end + if #cool_eps > 0 then + table.insert(main_component_capabilities, capabilities.thermostatCoolingSetpoint.ID) + end + + if running_state_supported then + table.insert(main_component_capabilities, capabilities.thermostatOperatingState.ID) + end + + table.insert(optional_supported_component_capabilities, {"main", main_component_capabilities}) + device:try_update_metadata({profile = profile_name, optional_component_capabilities = optional_supported_component_capabilities}) + + -- add mandatory capabilities for subscription + local total_supported_capabilities = optional_supported_component_capabilities + table.insert(main_component_capabilities, capabilities.switch.ID) + table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + table.insert(main_component_capabilities, capabilities.thermostatMode.ID) + table.insert(main_component_capabilities, capabilities.refresh.ID) + table.insert(main_component_capabilities, capabilities.firmwareUpdate.ID) + + device:set_field(SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) +end + +local function match_modular_profile(driver, device) + if profiling_data_still_required(device) then return end + + local device_type = get_device_type(device) + local thermostat_eps = device:get_endpoints(clusters.Thermostat.ID) + + if device_type == AP_DEVICE_TYPE_ID then + match_modular_profile_air_purifer(driver, device) + elseif device_type == RAC_DEVICE_TYPE_ID then + match_modular_profile_room_ac(driver, device) + elseif #thermostat_eps > 0 then + match_modular_profile_thermostat(driver, device) + else + device.log.warn_with({hub_logs=true}, "Device type is not supported by modular profile in thermostat driver, trying profile switch instead") + match_profile_switch(driver, device) + return + end + + -- clear all profiling data fields after profiling is complete. + for _, field in pairs(profiling_data) do + device:set_field(field, nil) + end +end + +local function supports_modular_profile(device) + local device_type = get_device_type(device) + local thermostat_eps = device:get_endpoints(clusters.Thermostat.ID) + + return version.api >= 14 and version.rpc >= 8 and + (device_type == AP_DEVICE_TYPE_ID or + device_type == RAC_DEVICE_TYPE_ID or + (device_type == false and #thermostat_eps > 0)) +end + +function match_profile(driver, device) + if supports_modular_profile(device) then + match_modular_profile(driver, device) + else + match_profile_switch(driver, device) + end +end + local function do_configure(driver, device) match_profile(driver, device) end +local function driver_switched(driver, device) + match_profile(driver, device) +end + local function device_added(driver, device) local req = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {}) req:merge(clusters.Thermostat.attributes.ControlSequenceOfOperation:read(device)) @@ -1015,7 +1343,7 @@ local function temp_event_handler(attribute) elseif attribute == capabilities.thermostatHeatingSetpoint.heatingSetpoint then local MAX_TEMP_IN_C = THERMOSTAT_MAX_TEMP_IN_C local MIN_TEMP_IN_C = THERMOSTAT_MIN_TEMP_IN_C - local is_water_heater_device = get_device_type(driver, device) == WATER_HEATER_DEVICE_TYPE_ID + local is_water_heater_device = get_device_type(device) == WATER_HEATER_DEVICE_TYPE_ID if is_water_heater_device then MAX_TEMP_IN_C = WATER_HEATER_MAX_TEMP_IN_C MIN_TEMP_IN_C = WATER_HEATER_MIN_TEMP_IN_C @@ -1445,7 +1773,7 @@ local function set_setpoint(setpoint) local endpoint_id = component_to_endpoint(device, cmd.component, clusters.Thermostat.ID) local MAX_TEMP_IN_C = THERMOSTAT_MAX_TEMP_IN_C local MIN_TEMP_IN_C = THERMOSTAT_MIN_TEMP_IN_C - local is_water_heater_device = get_device_type(driver, device) == WATER_HEATER_DEVICE_TYPE_ID + local is_water_heater_device = get_device_type(device) == WATER_HEATER_DEVICE_TYPE_ID if is_water_heater_device then MAX_TEMP_IN_C = WATER_HEATER_MAX_TEMP_IN_C MIN_TEMP_IN_C = WATER_HEATER_MIN_TEMP_IN_C @@ -1532,7 +1860,7 @@ local heating_setpoint_limit_handler_factory = function(minOrMax) end local MAX_TEMP_IN_C = THERMOSTAT_MAX_TEMP_IN_C local MIN_TEMP_IN_C = THERMOSTAT_MIN_TEMP_IN_C - local is_water_heater_device = (get_device_type(driver, device) == WATER_HEATER_DEVICE_TYPE_ID) + local is_water_heater_device = (get_device_type(device) == WATER_HEATER_DEVICE_TYPE_ID) if is_water_heater_device then MAX_TEMP_IN_C = WATER_HEATER_MAX_TEMP_IN_C MIN_TEMP_IN_C = WATER_HEATER_MIN_TEMP_IN_C @@ -1832,7 +2160,8 @@ local matter_driver_template = { added = device_added, doConfigure = do_configure, infoChanged = info_changed, - removed = device_removed + removed = device_removed, + driverSwitched = driver_switched }, matter_handlers = { attr = { diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_modular.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_modular.lua new file mode 100644 index 0000000000..009cdc700b --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_air_purifier_modular.lua @@ -0,0 +1,368 @@ +-- Copyright 2024 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local utils = require "st.utils" +local dkjson = require "dkjson" + +local clusters = require "st.matter.clusters" + +clusters.HepaFilterMonitoring = require "HepaFilterMonitoring" +clusters.ActivatedCarbonFilterMonitoring = require "ActivatedCarbonFilterMonitoring" +clusters.AirQuality = require "AirQuality" +clusters.CarbonMonoxideConcentrationMeasurement = require "CarbonMonoxideConcentrationMeasurement" +clusters.CarbonDioxideConcentrationMeasurement = require "CarbonDioxideConcentrationMeasurement" +clusters.FormaldehydeConcentrationMeasurement = require "FormaldehydeConcentrationMeasurement" +clusters.NitrogenDioxideConcentrationMeasurement = require "NitrogenDioxideConcentrationMeasurement" +clusters.OzoneConcentrationMeasurement = require "OzoneConcentrationMeasurement" +clusters.Pm1ConcentrationMeasurement = require "Pm1ConcentrationMeasurement" +clusters.Pm10ConcentrationMeasurement = require "Pm10ConcentrationMeasurement" +clusters.Pm25ConcentrationMeasurement = require "Pm25ConcentrationMeasurement" +clusters.RadonConcentrationMeasurement = require "RadonConcentrationMeasurement" +clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement = require "TotalVolatileOrganicCompoundsConcentrationMeasurement" + +test.set_rpc_version(8) + +local mock_device_basic = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-wind.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + }, + device_types = { + {device_type_id = 0x002D, device_type_revision = 1} -- AP + } + } + } +}) + +local mock_device_ap_thermo_aqs = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("air-purifier-hepa-ac-rock-wind-thermostat-humidity-fan-heating-only-nostate-nobattery-aqs-pm10-pm25-ch2o-meas-pm10-pm25-ch2o-no2-tvoc-level.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.HepaFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + {cluster_id = clusters.ActivatedCarbonFilterMonitoring.ID, cluster_type = "SERVER", feature_map = 7}, + }, + device_types = { + {device_type_id = 0x002D, device_type_revision = 1} -- AP + } + }, + { + endpoint_id = 3, + clusters = { + {cluster_id = clusters.AirQuality.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.NitrogenDioxideConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14}, + {cluster_id = clusters.Pm25ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.FormaldehydeConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.Pm10ConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 15}, + {cluster_id = clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.ID, cluster_type = "SERVER", feature_map = 14}, + }, + device_types = { + {device_type_id = 0x002C, device_type_revision = 1} -- AQS + } + }, + { + endpoint_id = 4, + clusters = { + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0302, device_type_revision = 1} -- Temperature Sensor + } + }, + { + endpoint_id = 6, + clusters = { + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0307, device_type_revision = 1} -- Humidity Sensor + } + }, + { + endpoint_id = 7, + clusters = { + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 1}, + }, + device_types = { + {device_type_id = 0x0301, device_type_revision = 1} -- Thermostat + } + }, + } +}) + +local cluster_subscribe_list = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.PercentCurrent, + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition, +} + +local cluster_subscribe_list_configured = { + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + }, + [capabilities.airPurifierFanMode.ID] = { + clusters.FanControl.attributes.FanModeSequence, + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting, + clusters.FanControl.attributes.RockSupport, + clusters.FanControl.attributes.RockSetting, + }, + [capabilities.filterState.ID] = { + clusters.HepaFilterMonitoring.attributes.Condition, + clusters.ActivatedCarbonFilterMonitoring.attributes.Condition + }, + [capabilities.filterStatus.ID] = { + clusters.HepaFilterMonitoring.attributes.ChangeIndication, + clusters.ActivatedCarbonFilterMonitoring.attributes.ChangeIndication + }, + [capabilities.airQualityHealthConcern.ID] = { + clusters.AirQuality.attributes.AirQuality + }, + [capabilities.nitrogenDioxideHealthConcern.ID] = { + clusters.NitrogenDioxideConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.formaldehydeMeasurement.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasuredValue, + clusters.FormaldehydeConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.formaldehydeHealthConcern.ID] = { + clusters.FormaldehydeConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.fineDustHealthConcern.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.dustSensor.ID] = { + clusters.Pm25ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm25ConcentrationMeasurement.attributes.MeasurementUnit, + clusters.Pm10ConcentrationMeasurement.attributes.MeasuredValue, + clusters.Pm10ConcentrationMeasurement.attributes.MeasurementUnit, + }, + [capabilities.dustHealthConcern.ID] = { + clusters.Pm10ConcentrationMeasurement.attributes.LevelValue, + }, + [capabilities.tvocHealthConcern.ID] = { + clusters.TotalVolatileOrganicCompoundsConcentrationMeasurement.attributes.LevelValue + } +} + +local function test_init_basic() + local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_basic) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_basic)) + end + end + test.socket.matter:__expect_send({mock_device_basic.id, subscribe_request}) + test.mock_device.add_test_device(mock_device_basic) +end + +local function test_init_ap_thermo_aqs_preconfigured() + local subscribe_request = nil + for _, attributes in pairs(cluster_subscribe_list_configured) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device_ap_thermo_aqs) + else + subscribe_request:merge(attribute:subscribe(mock_device_ap_thermo_aqs)) + end + end + end + test.socket.matter:__expect_send({mock_device_ap_thermo_aqs.id, subscribe_request}) + test.mock_device.add_test_device(mock_device_ap_thermo_aqs) +end + +local expected_update_metadata= { + optional_component_capabilities={ + { + "main", + {}, + }, + { + "activatedCarbonFilter", + { + "filterState", + "filterStatus", + }, + }, + { + "hepaFilter", + { + "filterState", + "filterStatus", + }, + }, + }, + profile="air-purifier-modular", +} + +local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_basic) + for i, cluster in ipairs(cluster_subscribe_list) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(mock_device_basic)) + end + end + +test.register_coroutine_test( + "Test profile change on init for basic Air Purifier device", + function() + mock_device_basic:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + mock_device_basic:set_field("__THERMOSTAT_RUNNING_STATE_SUPPORT", false) -- since we're assuming this would have happened during device_added in this case. + test.socket.device_lifecycle:__queue_receive({ mock_device_basic.id, "doConfigure" }) + mock_device_basic:expect_metadata_update(expected_update_metadata) + mock_device_basic:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + local device_info_copy = utils.deep_copy(mock_device_basic.raw_st_data) + device_info_copy.profile.id = "air-purifier-modular" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ mock_device_basic.id, "infoChanged", device_info_json }) + test.socket.matter:__expect_send({mock_device_basic.id, subscribe_request}) + end, + { test_init = test_init_basic } +) + +local expected_update_metadata= { + optional_component_capabilities={ + { + "main", + { + "relativeHumidityMeasurement", + "temperatureMeasurement", + "fanOscillationMode", + "windMode", + "thermostatMode", + "thermostatHeatingSetpoint", + "airQualityHealthConcern", + "dustSensor", + "fineDustSensor", + "formaldehydeMeasurement", + "dustHealthConcern", + "fineDustHealthConcern", + "formaldehydeHealthConcern", + "nitrogenDioxideHealthConcern", + "tvocHealthConcern", + }, + }, + { + "activatedCarbonFilter", + { + "filterState", + "filterStatus", + }, + }, + { + "hepaFilter", + { + "filterState", + "filterStatus", + }, + }, + }, + profile="air-purifier-modular", +} + +local subscribe_request = nil +for _, attributes in pairs(cluster_subscribe_list_configured) do + print("Adding attribute to subscribe", attributes) + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(mock_device_ap_thermo_aqs) + else + subscribe_request:merge(attribute:subscribe(mock_device_ap_thermo_aqs)) + end + end +end + +test.register_coroutine_test( + "Test profile change on init for AP and Thermo and AQS combined device type", + function() + mock_device_ap_thermo_aqs:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + mock_device_ap_thermo_aqs:set_field("__THERMOSTAT_RUNNING_STATE_SUPPORT", false) -- since we're assuming this would have happened during device_added in this case. + test.socket.device_lifecycle:__queue_receive({ mock_device_ap_thermo_aqs.id, "doConfigure" }) + mock_device_ap_thermo_aqs:expect_metadata_update(expected_update_metadata) + mock_device_ap_thermo_aqs:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + local device_info_copy = utils.deep_copy(mock_device_ap_thermo_aqs.raw_st_data) + device_info_copy.profile.id = "air-purifier-modular" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ mock_device_ap_thermo_aqs.id, "infoChanged", device_info_json }) + test.socket.matter:__expect_send({mock_device_ap_thermo_aqs.id, subscribe_request}) + end, + { test_init = test_init_ap_thermo_aqs_preconfigured } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac_modular.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac_modular.lua new file mode 100644 index 0000000000..d4258bbdb3 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_room_ac_modular.lua @@ -0,0 +1,325 @@ +-- Copyright 2024 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local utils = require "st.utils" +local dkjson = require "dkjson" + +local clusters = require "st.matter.clusters" + +test.set_rpc_version(8) + +local mock_device_basic = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("room-air-conditioner.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0072, device_type_revision = 1} -- Room Air Conditioner + } + } + } +}) + +local mock_device_no_state = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("room-air-conditioner.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.Thermostat.ID, cluster_type = "SERVER", feature_map = 63}, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER", feature_map = 0}, + }, + device_types = { + {device_type_id = 0x0072, device_type_revision = 1} -- Room Air Conditioner + } + } + } +}) + +local function initialize_mock_device(generic_mock_device, generic_subscribed_attributes) + local subscribe_request = nil + for _, attributes in pairs(generic_subscribed_attributes) do + for _, attribute in ipairs(attributes) do + if subscribe_request == nil then + subscribe_request = attribute:subscribe(generic_mock_device) + else + subscribe_request:merge(attribute:subscribe(generic_mock_device)) + end + end + end + test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) + test.mock_device.add_test_device(generic_mock_device) + return subscribe_request +end + +local subscribe_request_basic +local function test_init_basic() + local subscribed_attributes = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatOperatingState.ID] = { + clusters.Thermostat.attributes.ThermostatRunningState + }, + [capabilities.thermostatCoolingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit + }, + [capabilities.airConditionerFanMode.ID] = { + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting + }, + } + subscribe_request_basic = initialize_mock_device(mock_device_basic, subscribed_attributes) + local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() + test.socket.matter:__expect_send({mock_device_basic.id, read_setpoint_deadband}) +end + +local subscribed_attributes_no_state = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatOperatingState.ID] = { + clusters.Thermostat.attributes.ThermostatRunningState + }, + [capabilities.thermostatCoolingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit + }, + [capabilities.airConditionerFanMode.ID] = { + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting + }, + } + +local subscribe_request_no_state = nil +for _, attributes in pairs(subscribed_attributes_no_state) do + for _, attribute in ipairs(attributes) do + if subscribe_request_no_state == nil then + subscribe_request_no_state = attribute:subscribe(mock_device_no_state) + else + subscribe_request_no_state:merge(attribute:subscribe(mock_device_no_state)) + end + end +end + + +local function test_init_no_state() + local subscribed_attributes = { + [capabilities.switch.ID] = { + clusters.OnOff.attributes.OnOff + }, + [capabilities.temperatureMeasurement.ID] = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue + }, + [capabilities.relativeHumidityMeasurement.ID] = { + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue + }, + [capabilities.thermostatMode.ID] = { + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ControlSequenceOfOperation + }, + [capabilities.thermostatOperatingState.ID] = { + clusters.Thermostat.attributes.ThermostatRunningState + }, + [capabilities.thermostatCoolingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit + }, + [capabilities.airConditionerFanMode.ID] = { + clusters.FanControl.attributes.FanMode + }, + [capabilities.fanSpeedPercent.ID] = { + clusters.FanControl.attributes.PercentCurrent + }, + [capabilities.windMode.ID] = { + clusters.FanControl.attributes.WindSupport, + clusters.FanControl.attributes.WindSetting + }, + } + + -- initially, device onboards WITH thermostatOperatingState, the test below will + -- check if it is removed correctly when switching to modular profile. This is done + -- to test that cases where the modular profile is different from the static profile + -- work correctly, and thermostatOperatingState is simple to remove in these + -- test cases via the device field + initialize_mock_device(mock_device_no_state, subscribed_attributes) + local read_setpoint_deadband = clusters.Thermostat.attributes.MinSetpointDeadBand:read() + test.socket.matter:__expect_send({mock_device_no_state.id, read_setpoint_deadband}) +end + +-- run the profile configuration tests +local function test_room_ac_device_type_update_modular_profile(generic_mock_device, expected_metadata, subscribe_request) + test.socket.device_lifecycle:__queue_receive({generic_mock_device.id, "doConfigure"}) + generic_mock_device:expect_metadata_update(expected_metadata) + generic_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + local device_info_copy = utils.deep_copy(generic_mock_device.raw_st_data) + device_info_copy.profile.id = "room-air-conditioner-modular" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ generic_mock_device.id, "infoChanged", device_info_json }) + test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) +end + +local expected_metadata_basic= { + optional_component_capabilities={ + { + "main", + { + "relativeHumidityMeasurement", + "airConditionerFanMode", + "fanSpeedPercent", + "windMode", + "thermostatHeatingSetpoint", + "thermostatCoolingSetpoint", + "thermostatOperatingState" + }, + } + }, + profile="room-air-conditioner-modular", +} + +test.register_coroutine_test( + "Device with modular profile should enable correct optional capabilities - basic", + function() + mock_device_basic:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + mock_device_basic:set_field("__THERMOSTAT_RUNNING_STATE_SUPPORT", true) -- since we're assuming this would have happened during device_added in this case. + test_room_ac_device_type_update_modular_profile(mock_device_basic, expected_metadata_basic, subscribe_request_basic) + end, + { test_init = test_init_basic } +) + +local expected_metadata_no_state = { + optional_component_capabilities={ + { + "main", + { + "relativeHumidityMeasurement", + "airConditionerFanMode", + "fanSpeedPercent", + "windMode", + "thermostatHeatingSetpoint", + "thermostatCoolingSetpoint", + }, + } + }, + profile="room-air-conditioner-modular", +} + +test.register_coroutine_test( + "Device with modular profile should enable correct optional capabilities - no thermo state", + function() + mock_device_no_state:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + mock_device_no_state:set_field("__THERMOSTAT_RUNNING_STATE_SUPPORT", false) -- since we're assuming this would have happened during device_added in this case. + test_room_ac_device_type_update_modular_profile(mock_device_no_state, expected_metadata_no_state, subscribe_request_no_state) + end, + { test_init = test_init_no_state } +) +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua index 7bbc59197a..59ae9a433f 100644 --- a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermo_featuremap.lua @@ -253,7 +253,13 @@ test.register_coroutine_test( end test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({})) + -- profile name does not matter, we just check that the name is different in the info_changed handler + local updates = { + profile = { + id = "new-profile" + } + } + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed(updates)) end ) diff --git a/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua new file mode 100644 index 0000000000..31e15216d6 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/src/test/test_matter_thermostat_modular.lua @@ -0,0 +1,133 @@ +-- Copyright 2023 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local utils = require "st.utils" +local dkjson = require "dkjson" + +local clusters = require "st.matter.clusters" + +test.set_rpc_version(8) + +local mock_device_basic = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("thermostat-humidity-fan.yml"), + manufacturer_info = { + vendor_id = 0x0000, + product_id = 0x0000, + }, + endpoints = { + { + endpoint_id = 0, + clusters = { + {cluster_id = clusters.Basic.ID, cluster_type = "SERVER"}, + }, + device_types = { + device_type_id = 0x0016, device_type_revision = 1, -- RootNode + } + }, + { + endpoint_id = 1, + clusters = { + {cluster_id = clusters.FanControl.ID, cluster_type = "SERVER", feature_map = 0}, + { + cluster_id = clusters.Thermostat.ID, + cluster_revision=5, + cluster_type="SERVER", + feature_map=3, -- Heat and Cool features + }, + {cluster_id = clusters.TemperatureMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.RelativeHumidityMeasurement.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.PowerSource.ID, cluster_type = "SERVER"}, + } + } + } +}) + +-- create test_init functions +local function initialize_mock_device(generic_mock_device, generic_subscribed_attributes) + local subscribe_request = generic_subscribed_attributes[1]:subscribe(generic_mock_device) + for i, cluster in ipairs(generic_subscribed_attributes) do + if i > 1 then + subscribe_request:merge(cluster:subscribe(generic_mock_device)) + end + end + test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) + test.mock_device.add_test_device(generic_mock_device) + return subscribe_request +end + +local subscribe_request_basic +local function test_init() + local subscribed_attributes = { + clusters.Thermostat.attributes.LocalTemperature, + clusters.Thermostat.attributes.OccupiedCoolingSetpoint, + clusters.Thermostat.attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.attributes.AbsMinCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMaxCoolSetpointLimit, + clusters.Thermostat.attributes.AbsMinHeatSetpointLimit, + clusters.Thermostat.attributes.AbsMaxHeatSetpointLimit, + clusters.Thermostat.attributes.SystemMode, + clusters.Thermostat.attributes.ThermostatRunningState, + clusters.Thermostat.attributes.ControlSequenceOfOperation, + clusters.TemperatureMeasurement.attributes.MeasuredValue, + clusters.TemperatureMeasurement.attributes.MinMeasuredValue, + clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, + clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, + clusters.FanControl.attributes.FanMode, + clusters.FanControl.attributes.FanModeSequence, + clusters.PowerSource.attributes.BatPercentRemaining, + } + subscribe_request_basic = initialize_mock_device(mock_device_basic, subscribed_attributes) +end + +-- run the profile configuration tests +local function test_thermostat_device_type_update_modular_profile(generic_mock_device, expected_metadata, subscribe_request) + test.socket.device_lifecycle:__queue_receive({generic_mock_device.id, "doConfigure"}) + generic_mock_device:expect_metadata_update(expected_metadata) + generic_mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + local device_info_copy = utils.deep_copy(generic_mock_device.raw_st_data) + device_info_copy.profile.id = "thermostat-modular" + local device_info_json = dkjson.encode(device_info_copy) + test.socket.device_lifecycle:__queue_receive({ generic_mock_device.id, "infoChanged", device_info_json }) + test.socket.matter:__expect_send({generic_mock_device.id, subscribe_request}) +end + +local expected_metadata = { + optional_component_capabilities={ + { + "main", + { + "relativeHumidityMeasurement", + "thermostatFanMode", + "thermostatHeatingSetpoint", + "thermostatCoolingSetpoint" + }, + }, + }, + profile="thermostat-modular", +} + +test.register_coroutine_test( + "Device with modular profile should enable correct optional capabilities", + function() + mock_device_basic:set_field("__BATTERY_SUPPORT", "NO_BATTERY") -- since we're assuming this would have happened during device_added in this case. + mock_device_basic:set_field("__THERMOSTAT_RUNNING_STATE_SUPPORT", false) -- since we're assuming this would have happened during device_added in this case. + test_thermostat_device_type_update_modular_profile(mock_device_basic, expected_metadata, subscribe_request_basic) + end, + { test_init = test_init } +) + +-- run tests +test.run_registered_tests()