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..b21511b4f7 --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/air-purifier-modular.yml @@ -0,0 +1,54 @@ +name: air-purifier-modular +components: + - id: main + capabilities: + - id: airPurifierFanMode + - id: fanSpeedPercent + - id: fanOscillationMode + optional: true + - id: windMode + optional: true + - id: firmwareUpdate + - id: refresh + - id: thermostatHeatingSetpoint + optional: true + - id: thermostatMode + optional: true + - id: temperatureMeasurement + optional: true + - id: dustSensor + optional: true + - id: formaldehydeMeasurement + optional: true + - id: relativeHumidityMeasurement + optional: true + - id: airQualityHealthConcern + optional: true + - id: dustHealthConcern + optional: true + - id: fineDustHealthConcern + optional: true + - id: formaldehydeHealthConcern + optional: true + - id: nitrogenDioxideHealthConcern + optional: true + - id: tvocHealthConcern + optional: true + - id: thermostatOperatingState + optional: true + - id: fineDustSensor + optional: true + - id: activatedCarbonFilter + optional: true + capabilities: + - id: filterState + optional: true + - id: filterStatus + optional: true + - id: hepaFilter + optional: true + capabilities: + - id: filterState + optional: true + - id: filterStatus + optional: true 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..83a0a8e87c --- /dev/null +++ b/drivers/SmartThings/matter-thermostat/profiles/thermostat-modular.yml @@ -0,0 +1,29 @@ +name: thermostat-modular +components: + - id: main + capabilities: + - id: temperatureMeasurement + - id: thermostatMode + - id: thermostatHeatingSetpoint + optional: true + - id: thermostatCoolingSetpoint + optional: true + - id: thermostatOperatingState + optional: true + - id: batteryLevel + optional: true + - id: firmwareUpdate + - id: refresh + - id: battery + optional: true + - id: thermostatFanMode + optional: true + - id: relativeHumidityMeasurement + optional: true + categories: + - name: Thermostat +preferences: + - preferenceId: tempOffset + explicit: true + - preferenceId: humidityOffset + explicit: true diff --git a/drivers/SmartThings/matter-thermostat/src/init.lua b/drivers/SmartThings/matter-thermostat/src/init.lua index 69adcc3c7d..d4743762ec 100644 --- a/drivers/SmartThings/matter-thermostat/src/init.lua +++ b/drivers/SmartThings/matter-thermostat/src/init.lua @@ -21,6 +21,9 @@ local im = require "st.matter.interaction_model" local MatterDriver = require "st.matter.driver" local utils = require "st.utils" +local match_profile +local SUPPORTED_COMPONENT_CAPABILITIES = "__supported_component_capabilities" + -- Include driver-side definitions when lua libs api version is < 10 local version = require "version" if version.api < 10 then @@ -305,6 +308,21 @@ local subscribed_attributes = { }, } +local function supports_capability_by_id_modular(device, capability, component) + 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 @@ -480,6 +498,8 @@ local function device_init(driver, device) end end schedule_polls_for_cumulative_energy_imported(device) + + match_profile(driver, device) end local function info_changed(driver, device, event, args) @@ -576,6 +596,29 @@ 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 + device.log.info(string.format("Adding %s cap to table", cap_id)) + 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 + device.log.info(string.format("Adding %s cap to table", cap_id)) + 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 = "" @@ -657,7 +700,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) @@ -794,6 +837,347 @@ 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 match_modular_profile_room_ac(driver, device) + -- Mandatory capabilities for room AC: + -- + -- Possible supported capabilites for room AC: + -- + 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}) + -- Room AC does not support the rocking feature of FanControl. + -- local rock_eps = device:get_endpoints(clusters.FanControl.ID, {feature_bitmap = clusters.FanControl.types.Feature.ROCKING}) + + if #fan_eps > 0 then + table.insert(main_component_capabilities, capabilities.airConditionerFanMode.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(total_supported_capabilities[1][2], capabilities.temperatureMeasurement.ID) + table.insert(total_supported_capabilities[1][2], capabilities.switch.ID) + table.insert(total_supported_capabilities[1][2], capabilities.thermostatMode.ID) + + device:set_field(SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) + + --re-up subscription with new capabiltiies using the moudlar supports_capability override + device:extend_device("supports_capability_by_id", supports_capability_by_id_modular) + device:subscribe() +end + +local function match_modular_profile_fan(driver, device) + -- Mandatory capabilities for fan: + -- + -- Possible supported capabilites for fan: + -- + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + local profile_name = "fan-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 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.airConditionerFanMode.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 + + 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(total_supported_capabilities[1][2], capabilities.airConditionerFanMode.ID) + table.insert(total_supported_capabilities[1][2], capabilities.fanSpeedPercent.ID) + + device:set_field(SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) + + --re-up subscription with new capabiltiies using the moudlar supports_capability override + device:extend_device("supports_capability_by_id", supports_capability_by_id_modular) + device:subscribe() +end + +local function get_air_quality_optional_capabilities(device) + local optional_supported_component_capabilities = {} + 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) + -- Mandatory capabilities for air purifier: + -- + -- Possible supported capabilites for air purifier: + -- + local humidity_eps = device:get_endpoints(clusters.RelativeHumidityMeasurement.ID) + + local optional_supported_component_capabilities = {} + local main_component_capabilities = {} + local hepa_filter_component_capabilities = {} + local ac_filter_component_capabiltiies = {} + local profile_name = "air-purifier-modular" + + if #humidity_eps > 0 then + table.insert(main_component_capabilities, capabilities.relativeHumidityMeasurement.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 + -- TODO: only one of these is required by spec + table.insert(hepa_filter_component_capabilities, capabilities.filterState.ID) + table.insert(hepa_filter_component_capabilities, capabilities.filterStatus.ID) + end + if #ac_filter_eps > 0 then + -- TODO: only one of these is required by spec + table.insert(ac_filter_component_capabiltiies, capabilities.filterState.ID) + table.insert(ac_filter_component_capabiltiies, capabilities.filterStatus.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}) + + -- This capability will be mandatory + -- if #fan_eps > 0 then + -- table.insert(main_component_capabilities, capabilities.airPurifierFanMode.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_eps = device:get_endpoints(clusters.Thermostat.ID) + + if #thermostat_eps > 0 then + -- thermostatMode and temperatureMeasurement are mandatory if thermostat is present? + table.insert(main_component_capabilities, capabilities.thermostatMode.ID) + table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + 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 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 + + 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_capabiltiies > 0 then + table.insert(optional_supported_component_capabilities, {"activatedCarbonFilter", ac_filter_component_capabiltiies}) + 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 + -- TODO: make sure these are added to the main component list, even though it theoretically shouldn't matter + -- however, the numbering is thrown off if there are other components for hepa/AC filter + table.insert(total_supported_capabilities[1][2], capabilities.airPurifierFanMode.ID) + table.insert(total_supported_capabilities[1][2], capabilities.fanSpeedPercent.ID) + + device:set_field(SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) + + --re-up subscription with new capabiltiies using the moudlar supports_capability override + device:extend_device("supports_capability_by_id", supports_capability_by_id_modular) + device:subscribe() +end + +local function match_modular_profile_thermostat(driver, device) + -- Mandatory capabilities for thermostat: + -- + -- Possible supported capabilites for thermostat: + -- + 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.airConditionerFanMode.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 + -- TODO: make sure these are added to the main component list, even though it theoretically shouldn't matter + -- however, the numbering is thrown off if there are other components for hepa/AC filter + table.insert(main_component_capabilities, capabilities.thermostatMode.ID) + table.insert(main_component_capabilities, capabilities.temperatureMeasurement.ID) + + device:set_field(SUPPORTED_COMPONENT_CAPABILITIES, total_supported_capabilities, { persist = true }) + + --re-up subscription with new capabiltiies using the moudlar supports_capability override + device:extend_device("supports_capability_by_id", supports_capability_by_id_modular) + device:subscribe() +end + +local function match_modular_profile(driver, device) + -- commented out for testing + -- if profiling_data_still_required(device) then return end + + -- TODO: test device refresh + device:extend_device("supports_capability_by_id", supports_capability_by_id_modular) + + local device_type = get_device_type(driver, device) + local thermostat_eps = device:get_endpoints(clusters.Thermostat.ID) + + if device_type == RAC_DEVICE_TYPE_ID then + match_modular_profile_room_ac(driver, device) + elseif device_type == FAN_DEVICE_TYPE_ID then + match_modular_profile_fan(driver, device) + elseif device_type == AP_DEVICE_TYPE_ID then + match_modular_profile_air_purifer(driver, device) + elseif device_type == WATER_HEATER_DEVICE_TYPE_ID then + -- TODO + elseif device_type == HEAT_PUMP_DEVICE_TYPE_ID then + -- TODO + elseif #thermostat_eps > 0 then + match_modular_profile_thermostat(driver, device) + else + device.log.warn_with({hub_logs=true}, "Device type is not supported in thermostat driver") + 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 + +function match_profile(driver, device) + -- must use profile switching on older hubs + -- TODO update to RPC version 8 + if version.api < 14 or version.rpc < 7 then + match_profile_switch(driver, device) + else + match_modular_profile(driver, device) + end +end + local function do_configure(driver, device) match_profile(driver, device) end