Skip to content

Commit 7d8b6fe

Browse files
Matter Switch: Support Fan/Light device (#2032)
This change adds support for fan+color light devices, brings in handlers for fan attributes and capabilities, and adds new logic to select the fan+light new profile and create the component map.
1 parent de08c2b commit 7d8b6fe

File tree

4 files changed

+479
-66
lines changed

4 files changed

+479
-66
lines changed

drivers/SmartThings/matter-switch/fingerprints.yml

+6
Original file line numberDiff line numberDiff line change
@@ -2740,6 +2740,12 @@ matterGeneric:
27402740
- id: 0x0101 # Dimmable Light
27412741
- id: 0x000F # Generic Switch
27422742
deviceProfileName: light-level-button
2743+
- id: "matter/color/light/fan"
2744+
deviceLabel: Matter Color Light Fan
2745+
deviceTypes:
2746+
- id: 0x010D # Extended Color Light
2747+
- id: 0x002B # Fan
2748+
deviceProfileName: light-color-level-fan
27432749

27442750
matterThing:
27452751
- id: SmartThings/MatterThing

drivers/SmartThings/matter-switch/profiles/light-color-level-fan.yml

+4-8
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,13 @@ components:
1818
range: [ 2200, 6500 ]
1919
- id: colorControl
2020
version: 1
21+
- id: fanMode
22+
version: 1
23+
- id: fanSpeedPercent
24+
version: 1
2125
- id: firmwareUpdate
2226
version: 1
2327
- id: refresh
2428
version: 1
2529
categories:
2630
- name: Light
27-
- id: fan
28-
capabilities:
29-
- id: fanMode
30-
version: 1
31-
- id: fanSpeedPercent
32-
version: 1
33-
categories:
34-
- name: Fan

drivers/SmartThings/matter-switch/src/init.lua

+166-58
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ local lua_socket = require "socket"
2121
local utils = require "st.utils"
2222
local device_lib = require "st.device"
2323
local embedded_cluster_utils = require "embedded-cluster-utils"
24-
-- Include driver-side definitions when lua libs api version is < 11
2524
local version = require "version"
25+
26+
-- Include driver-side definitions when lua libs api version is < 11
2627
if version.api < 11 then
2728
clusters.ElectricalEnergyMeasurement = require "ElectricalEnergyMeasurement"
2829
clusters.ElectricalPowerMeasurement = require "ElectricalPowerMeasurement"
@@ -515,7 +516,8 @@ local function do_configure(driver, device)
515516
if device:get_field(BUTTON_DEVICE_PROFILED) then
516517
return
517518
end
518-
local level_eps = embedded_cluster_utils.get_endpoints(device, clusters.LevelControl.ID)
519+
local fan_eps = device:get_endpoints(clusters.FanControl.ID)
520+
local level_eps = device:get_endpoints(clusters.LevelControl.ID)
519521
local energy_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalEnergyMeasurement.ID)
520522
local power_eps = embedded_cluster_utils.get_endpoints(device, clusters.ElectricalPowerMeasurement.ID)
521523
local valve_eps = embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID)
@@ -536,6 +538,8 @@ local function do_configure(driver, device)
536538
{feature_bitmap = clusters.ValveConfigurationAndControl.types.Feature.LEVEL}) > 0 then
537539
profile_name = profile_name .. "-level"
538540
end
541+
elseif #fan_eps > 0 then
542+
profile_name = "light-color-level-fan"
539543
end
540544

541545
if profile_name then
@@ -585,46 +589,44 @@ local function find_child(parent, ep_id)
585589
return parent:get_child_by_parent_assigned_key(string.format("%d", ep_id))
586590
end
587591

588-
local function try_build_button_component_map(device, main_endpoint, button_eps)
592+
local function build_button_component_map(device, main_endpoint, button_eps)
589593
-- create component mapping on the main profile button endpoints
590-
if STATIC_BUTTON_PROFILE_SUPPORTED[#button_eps] then
591-
local component_map = {}
592-
component_map["main"] = main_endpoint
593-
for component_num, ep in ipairs(button_eps) do
594-
if ep ~= main_endpoint then
595-
local button_component = "button" .. component_num
596-
component_map[button_component] = ep
594+
table.sort(button_eps)
595+
local component_map = {}
596+
component_map["main"] = main_endpoint
597+
for component_num, ep in ipairs(button_eps) do
598+
if ep ~= main_endpoint then
599+
local button_component = "button"
600+
if #button_eps > 1 then
601+
button_component = button_component .. component_num
597602
end
603+
component_map[button_component] = ep
598604
end
599-
device:set_field(COMPONENT_TO_ENDPOINT_MAP_BUTTON, component_map, {persist = true})
600605
end
606+
device:set_field(COMPONENT_TO_ENDPOINT_MAP_BUTTON, component_map, {persist = true})
601607
end
602608

603609
local function build_button_profile(device, main_endpoint, num_button_eps)
604-
local profile_name
605-
local battery_supported
610+
local profile_name = string.gsub(num_button_eps .. "-button", "1%-", "") -- remove the "1-" in a device with 1 button ep
606611
if device_type_supports_button_switch_combination(device, main_endpoint) then
607-
profile_name = "light-level-" .. num_button_eps .. "-button"
608-
else
609-
profile_name = num_button_eps .. "-button"
610-
battery_supported = #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0
611-
if battery_supported then
612-
local attribute_list_read = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {})
613-
attribute_list_read:merge(clusters.PowerSource.attributes.AttributeList:read())
614-
device:send(attribute_list_read)
615-
end
612+
profile_name = "light-level-" .. profile_name
616613
end
617-
618-
if not battery_supported then -- battery profiles are configured later, in power_source_attribute_list_handler
619-
profile_name = string.gsub(profile_name, "1%-", "") -- remove the "1-" in a device with 1 button ep
614+
local battery_supported = #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0
615+
if battery_supported then -- battery profiles are configured later, in power_source_attribute_list_handler
616+
local attribute_list_read = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {})
617+
attribute_list_read:merge(clusters.PowerSource.attributes.AttributeList:read())
618+
device:send(attribute_list_read)
619+
else
620620
device:try_update_metadata({profile = profile_name})
621621
end
622622
device:set_field(BUTTON_DEVICE_PROFILED, true)
623623
end
624624

625-
local function try_build_child_switch_profiles(driver, device, switch_eps, main_endpoint)
625+
local function build_child_switch_profiles(driver, device, main_endpoint)
626626
local num_switch_server_eps = 0
627627
local parent_child_device = false
628+
local switch_eps = device:get_endpoints(clusters.OnOff.ID)
629+
table.sort(switch_eps)
628630
for _, ep in ipairs(switch_eps) do
629631
if device:supports_server_cluster(clusters.OnOff.ID, ep) then
630632
num_switch_server_eps = num_switch_server_eps + 1
@@ -656,58 +658,52 @@ local function try_build_child_switch_profiles(driver, device, switch_eps, main_
656658
device:set_field(IS_PARENT_CHILD_DEVICE, true, {persist = true})
657659
end
658660

659-
device:set_field(SWITCH_INITIALIZED, true, {persist = true})
660-
661661
-- this is needed in initialize_buttons_and_switches
662662
return num_switch_server_eps
663663
end
664664

665-
local function handle_light_switch_with_onOff_server_clusters(device, main_endpoint, num_switch_server_eps)
666-
local cluster_id = 0
667-
for _, ep in ipairs(device.endpoints) do
668-
-- main_endpoint only supports server cluster by definition of get_endpoints()
669-
if main_endpoint == ep.endpoint_id then
670-
for _, dt in ipairs(ep.device_types) do
671-
-- no device type that is not in the switch subset should be considered.
672-
if (ON_OFF_SWITCH_ID <= dt.device_type_id and dt.device_type_id <= ON_OFF_COLOR_DIMMER_SWITCH_ID) then
673-
cluster_id = math.max(cluster_id, dt.device_type_id)
674-
end
665+
local function handle_light_switch_with_onOff_server_clusters(device, main_endpoint)
666+
local cluster_id = 0
667+
for _, ep in ipairs(device.endpoints) do
668+
-- main_endpoint only supports server cluster by definition of get_endpoints()
669+
if main_endpoint == ep.endpoint_id then
670+
for _, dt in ipairs(ep.device_types) do
671+
-- no device type that is not in the switch subset should be considered.
672+
if (ON_OFF_SWITCH_ID <= dt.device_type_id and dt.device_type_id <= ON_OFF_COLOR_DIMMER_SWITCH_ID) then
673+
cluster_id = math.max(cluster_id, dt.device_type_id)
675674
end
676-
break
677675
end
676+
break
678677
end
678+
end
679679

680-
if device_type_profile_map[cluster_id] then
681-
device:try_update_metadata({profile = device_type_profile_map[cluster_id]})
682-
end
680+
if device_type_profile_map[cluster_id] then
681+
device:try_update_metadata({profile = device_type_profile_map[cluster_id]})
682+
end
683683
end
684684

685685
local function initialize_buttons_and_switches(driver, device, main_endpoint)
686-
local switch_eps = device:get_endpoints(clusters.OnOff.ID)
687686
local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})
688-
table.sort(switch_eps)
689-
table.sort(button_eps)
690-
691-
-- All button endpoints found will be added as additional components in the profile containing the main_endpoint.
692-
-- The resulting endpoint to component map is saved in the COMPONENT_TO_ENDPOINT_MAP_BUTTON field
693-
try_build_button_component_map(device, main_endpoint, button_eps)
694-
695-
-- Without support for bindings, only clusters that are implemented as server are counted. This count is handled
696-
-- while building switch child profiles
697-
local num_switch_server_eps = try_build_child_switch_profiles(driver, device, switch_eps, main_endpoint)
698-
699-
if #button_eps > 0 then
687+
if tbl_contains(STATIC_BUTTON_PROFILE_SUPPORTED, #button_eps) then
700688
build_button_profile(device, main_endpoint, #button_eps)
689+
-- All button endpoints found will be added as additional components in the profile containing the main_endpoint.
690+
-- The resulting endpoint to component map is saved in the COMPONENT_TO_ENDPOINT_MAP_BUTTON field
691+
build_button_component_map(device, main_endpoint, button_eps)
701692
configure_buttons(device)
702-
return
703693
end
704694

695+
-- Without support for bindings, only clusters that are implemented as server are counted. This count is handled
696+
-- while building switch child profiles
697+
local num_switch_server_eps = build_child_switch_profiles(driver, device, main_endpoint)
698+
705699
-- We do not support the Light Switch device types because they require OnOff to be implemented as 'client', which requires us to support bindings.
706700
-- However, this workaround profiles devices that claim to be Light Switches, but that break spec and implement OnOff as 'server'.
707701
-- Note: since their device type isn't supported, these devices join as a matter-thing.
708702
if num_switch_server_eps > 0 and detect_matter_thing(device) then
709-
handle_light_switch_with_onOff_server_clusters(device, main_endpoint, num_switch_server_eps)
703+
handle_light_switch_with_onOff_server_clusters(device, main_endpoint)
710704
end
705+
706+
device:set_field(SWITCH_INITIALIZED, true, {persist = true})
711707
end
712708

713709
local function detect_bridge(device)
@@ -881,6 +877,31 @@ local function handle_set_level(driver, device, cmd)
881877
end
882878
end
883879

880+
local function set_fan_mode(driver, device, cmd)
881+
local fan_mode_id
882+
if cmd.args.fanMode == capabilities.fanMode.fanMode.low.NAME then
883+
fan_mode_id = clusters.FanControl.attributes.FanMode.LOW
884+
elseif cmd.args.fanMode == capabilities.fanMode.fanMode.medium.NAME then
885+
fan_mode_id = clusters.FanControl.attributes.FanMode.MEDIUM
886+
elseif cmd.args.fanMode == capabilities.fanMode.fanMode.high.NAME then
887+
fan_mode_id = clusters.FanControl.attributes.FanMode.HIGH
888+
elseif cmd.args.fanMode == capabilities.fanMode.fanMode.auto.NAME then
889+
fan_mode_id = clusters.FanControl.attributes.FanMode.AUTO
890+
else
891+
fan_mode_id = clusters.FanControl.attributes.FanMode.OFF
892+
end
893+
if fan_mode_id then
894+
local fan_ep = device:get_endpoints(clusters.FanControl.ID)[1]
895+
device:send(clusters.FanControl.attributes.FanMode:write(device, fan_ep, fan_mode_id))
896+
end
897+
end
898+
899+
local function set_fan_speed_percent(driver, device, cmd)
900+
local speed = math.floor(cmd.args.percent)
901+
local fan_ep = device:get_endpoints(clusters.FanControl.ID)[1]
902+
device:send(clusters.FanControl.attributes.PercentSetting:write(device, fan_ep, speed))
903+
end
904+
884905
local function handle_refresh(driver, device, cmd)
885906
--Note: no endpoint specified indicates a wildcard endpoint
886907
local req = clusters.OnOff.attributes.OnOff:read(device)
@@ -1338,6 +1359,73 @@ local function humidity_attr_handler(driver, device, ib, response)
13381359
end
13391360
end
13401361

1362+
local function fan_mode_handler(driver, device, ib, response)
1363+
if ib.data.value == clusters.FanControl.attributes.FanMode.OFF then
1364+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("off"))
1365+
elseif ib.data.value == clusters.FanControl.attributes.FanMode.LOW then
1366+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("low"))
1367+
elseif ib.data.value == clusters.FanControl.attributes.FanMode.MEDIUM then
1368+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("medium"))
1369+
elseif ib.data.value == clusters.FanControl.attributes.FanMode.HIGH then
1370+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("high"))
1371+
else
1372+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("auto"))
1373+
end
1374+
end
1375+
1376+
local function fan_mode_sequence_handler(driver, device, ib, response)
1377+
local supportedFanModes
1378+
if ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH then
1379+
supportedFanModes = {
1380+
capabilities.fanMode.fanMode.off.NAME,
1381+
capabilities.fanMode.fanMode.low.NAME,
1382+
capabilities.fanMode.fanMode.medium.NAME,
1383+
capabilities.fanMode.fanMode.high.NAME
1384+
}
1385+
elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH then
1386+
supportedFanModes = {
1387+
capabilities.fanMode.fanMode.off.NAME,
1388+
capabilities.fanMode.fanMode.low.NAME,
1389+
capabilities.fanMode.fanMode.high.NAME
1390+
}
1391+
elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO then
1392+
supportedFanModes = {
1393+
capabilities.fanMode.fanMode.off.NAME,
1394+
capabilities.fanMode.fanMode.low.NAME,
1395+
capabilities.fanMode.fanMode.medium.NAME,
1396+
capabilities.fanMode.fanMode.high.NAME,
1397+
capabilities.fanMode.fanMode.auto.NAME
1398+
}
1399+
elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH_AUTO then
1400+
supportedFanModes = {
1401+
capabilities.fanMode.fanMode.off.NAME,
1402+
capabilities.fanMode.fanMode.low.NAME,
1403+
capabilities.fanMode.fanMode.high.NAME,
1404+
capabilities.fanMode.fanMode.auto.NAME
1405+
}
1406+
elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_ON_AUTO then
1407+
supportedFanModes = {
1408+
capabilities.fanMode.fanMode.off.NAME,
1409+
capabilities.fanMode.fanMode.high.NAME,
1410+
capabilities.fanMode.fanMode.auto.NAME
1411+
}
1412+
else
1413+
supportedFanModes = {
1414+
capabilities.fanMode.fanMode.off.NAME,
1415+
capabilities.fanMode.fanMode.high.NAME
1416+
}
1417+
end
1418+
local event = capabilities.fanMode.supportedFanModes(supportedFanModes, {visibility = {displayed = false}})
1419+
device:emit_event_for_endpoint(ib.endpoint_id, event)
1420+
end
1421+
1422+
local function fan_speed_percent_attr_handler(driver, device, ib, response)
1423+
if ib.data.value == nil or ib.data.value < 0 or ib.data.value > 100 then
1424+
return
1425+
end
1426+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanSpeedPercent.percent(ib.data.value))
1427+
end
1428+
13411429
local matter_driver_template = {
13421430
lifecycle_handlers = {
13431431
init = device_init,
@@ -1399,6 +1487,11 @@ local matter_driver_template = {
13991487
[clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = temperature_attr_handler,
14001488
[clusters.TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temp_attr_handler_factory(TEMP_MIN),
14011489
[clusters.TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temp_attr_handler_factory(TEMP_MAX),
1490+
},
1491+
[clusters.FanControl.ID] = {
1492+
[clusters.FanControl.attributes.FanModeSequence.ID] = fan_mode_sequence_handler,
1493+
[clusters.FanControl.attributes.FanMode.ID] = fan_mode_handler,
1494+
[clusters.FanControl.attributes.PercentCurrent.ID] = fan_speed_percent_attr_handler
14021495
}
14031496
},
14041497
event = {
@@ -1464,6 +1557,13 @@ local matter_driver_template = {
14641557
clusters.TemperatureMeasurement.attributes.MeasuredValue,
14651558
clusters.TemperatureMeasurement.attributes.MinMeasuredValue,
14661559
clusters.TemperatureMeasurement.attributes.MaxMeasuredValue
1560+
},
1561+
[capabilities.fanMode.ID] = {
1562+
clusters.FanControl.attributes.FanModeSequence,
1563+
clusters.FanControl.attributes.FanMode
1564+
},
1565+
[capabilities.fanSpeedPercent.ID] = {
1566+
clusters.FanControl.attributes.PercentCurrent
14671567
}
14681568
},
14691569
subscribed_events = {
@@ -1499,6 +1599,12 @@ local matter_driver_template = {
14991599
},
15001600
[capabilities.level.ID] = {
15011601
[capabilities.level.commands.setLevel.NAME] = handle_set_level
1602+
},
1603+
[capabilities.fanMode.ID] = {
1604+
[capabilities.fanMode.commands.setFanMode.NAME] = set_fan_mode
1605+
},
1606+
[capabilities.fanSpeedPercent.ID] = {
1607+
[capabilities.fanSpeedPercent.commands.setPercent.NAME] = set_fan_speed_percent
15021608
}
15031609
},
15041610
supported_capabilities = {
@@ -1517,7 +1623,9 @@ local matter_driver_template = {
15171623
capabilities.battery,
15181624
capabilities.batteryLevel,
15191625
capabilities.temperatureMeasurement,
1520-
capabilities.relativeHumidityMeasurement
1626+
capabilities.relativeHumidityMeasurement,
1627+
capabilities.fanMode,
1628+
capabilities.fanSpeedPercent
15211629
},
15221630
sub_drivers = {
15231631
require("eve-energy"),

0 commit comments

Comments
 (0)