Skip to content

Commit feeaad8

Browse files
Matter Switch: Support Fan/Light device
This change adds a new profile for a fan+color light device, brings in handlers for fan attributes and capabilities, and adds new logic to select the new profile and create the component map.
1 parent 646fdb6 commit feeaad8

File tree

3 files changed

+439
-58
lines changed

3 files changed

+439
-58
lines changed

Diff for: drivers/SmartThings/matter-switch/fingerprints.yml

+6
Original file line numberDiff line numberDiff line change
@@ -2704,6 +2704,12 @@ matterGeneric:
27042704
- id: 0x0101 # Dimmable Light
27052705
- id: 0x000F # Generic Switch
27062706
deviceProfileName: light-level-button
2707+
- id: "matter/color/light/fan"
2708+
deviceLabel: Matter Color Light Fan
2709+
deviceTypes:
2710+
- id: 0x010D # Extended Color Light
2711+
- id: 0x002B # Fan
2712+
deviceProfileName: light-color-level-fan
27072713

27082714
matterThing:
27092715
- id: SmartThings/MatterThing

Diff for: drivers/SmartThings/matter-switch/src/init.lua

+191-58
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,9 @@ local SWITCH_INITIALIZED = "__switch_intialized"
5050
-- in the device table for devices that joined prior to this transition, and it
5151
-- will not be set for new devices.
5252
local COMPONENT_TO_ENDPOINT_MAP = "__component_to_endpoint_map"
53-
-- COMPONENT_TO_ENDPOINT_MAP_BUTTON is for devices with button endpoints, to
54-
-- preserve the MCD functionality for button devices from the matter-button
55-
-- driver after it was merged into the matter-switch driver. Note that devices
56-
-- containing both button endpoints and switch endpoints will use this field
57-
-- rather than COMPONENT_TO_ENDPOINT_MAP.
58-
local COMPONENT_TO_ENDPOINT_MAP_BUTTON = "__component_to_endpoint_map_button"
53+
-- COMPONENT_TO_ENDPOINT_MAP_NEW_DEVICES is for new devices that can be supported
54+
-- by a MCD configuration.
55+
local COMPONENT_TO_ENDPOINT_MAP_NEW_DEVICES = "__component_to_endpoint_map_button"
5956
local ENERGY_MANAGEMENT_ENDPOINT = "__energy_management_endpoint"
6057
local IS_PARENT_CHILD_DEVICE = "__is_parent_child_device"
6158
local COLOR_TEMP_BOUND_RECEIVED_KELVIN = "__colorTemp_bound_received_kelvin"
@@ -291,7 +288,7 @@ local HELD_THRESHOLD = 1
291288
-- this is the number of buttons for which we have a static profile already made
292289
local STATIC_BUTTON_PROFILE_SUPPORTED = {1, 2, 3, 4, 5, 6, 7, 8}
293290

294-
local BUTTON_DEVICE_PROFILED = "__button_device_profiled"
291+
local DEVICE_PROFILED = "__device_profiled"
295292

296293
-- Some switches will send a MultiPressComplete event as part of a long press sequence. Normally the driver will create a
297294
-- button capability event on receipt of MultiPressComplete, but in this case that would result in an extra event because
@@ -456,15 +453,15 @@ local function find_default_endpoint(device)
456453
end
457454

458455
local function component_to_endpoint(device, component)
459-
local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP_BUTTON) or device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {}
456+
local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP_NEW_DEVICES) or device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {}
460457
if map[component] then
461458
return map[component]
462459
end
463460
return find_default_endpoint(device)
464461
end
465462

466463
local function endpoint_to_component(device, ep)
467-
local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP_BUTTON) or device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {}
464+
local map = device:get_field(COMPONENT_TO_ENDPOINT_MAP_NEW_DEVICES) or device:get_field(COMPONENT_TO_ENDPOINT_MAP) or {}
468465
for component, endpoint in pairs(map) do
469466
if endpoint == ep then
470467
return component
@@ -513,7 +510,7 @@ local function assign_child_profile(device, child_ep)
513510
end
514511

515512
local function do_configure(driver, device)
516-
if device:get_field(BUTTON_DEVICE_PROFILED) then
513+
if device:get_field(DEVICE_PROFILED) then
517514
return
518515
end
519516
local level_eps = embedded_cluster_utils.get_endpoints(device, clusters.LevelControl.ID)
@@ -586,45 +583,66 @@ local function find_child(parent, ep_id)
586583
return parent:get_child_by_parent_assigned_key(string.format("%d", ep_id))
587584
end
588585

589-
local function try_build_button_component_map(device, main_endpoint, button_eps)
590-
-- create component mapping on the main profile button endpoints
591-
if STATIC_BUTTON_PROFILE_SUPPORTED[#button_eps] then
592-
local component_map = {}
593-
component_map["main"] = main_endpoint
594-
for component_num, ep in ipairs(button_eps) do
595-
if ep ~= main_endpoint then
596-
local button_component = "button" .. component_num
597-
component_map[button_component] = ep
586+
local function build_component_map(device, main_endpoint, endpoints)
587+
local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})
588+
local fan_eps = device:get_endpoints(clusters.FanControl.ID)
589+
local component_name
590+
591+
if #button_eps > 0 and STATIC_BUTTON_PROFILE_SUPPORTED[#button_eps] then
592+
component_name = "button"
593+
elseif #fan_eps > 0 then
594+
component_name = "fan"
595+
else
596+
device.log.warn_with({hub_logs = true}, "Device is not supported by a multicomponent configuration")
597+
return
598+
end
599+
600+
local component_map = {}
601+
component_map["main"] = main_endpoint
602+
for component_num, ep in ipairs(endpoints) do
603+
if ep ~= main_endpoint then
604+
local component = component_name
605+
if #endpoints > 1 then
606+
component = component .. component_num
598607
end
608+
component_map[component] = ep
599609
end
600-
device:set_field(COMPONENT_TO_ENDPOINT_MAP_BUTTON, component_map, {persist = true})
601610
end
611+
device:set_field(COMPONENT_TO_ENDPOINT_MAP_NEW_DEVICES, component_map, {persist = true})
602612
end
603613

604-
local function build_button_profile(device, main_endpoint, num_button_eps)
614+
local function build_mcd_profile(device, main_endpoint)
615+
local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})
616+
local fan_eps = device:get_endpoints(clusters.FanControl.ID)
605617
local profile_name
606618
local battery_supported
619+
607620
if device_type_supports_button_switch_combination(device, main_endpoint) then
608-
profile_name = "light-level-" .. num_button_eps .. "-button"
609-
else
610-
profile_name = num_button_eps .. "-button"
621+
profile_name = "light-level-" .. #button_eps .. "-button"
622+
elseif #button_eps > 0 and STATIC_BUTTON_PROFILE_SUPPORTED[#button_eps] then
623+
profile_name = #button_eps .. "-button"
611624
battery_supported = #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) > 0
612625
if device.manufacturer_info.vendor_id == HUE_MANUFACTURER_ID then battery_supported = false end -- no battery support in Hue case
613626
if battery_supported then
614627
local attribute_list_read = im.InteractionRequest(im.InteractionRequest.RequestType.READ, {})
615628
attribute_list_read:merge(clusters.PowerSource.attributes.AttributeList:read())
616629
device:send(attribute_list_read)
617630
end
631+
elseif #fan_eps > 0 then
632+
profile_name = "light-color-level-fan"
633+
else
634+
device.log.warn_with({hub_logs = true}, "Device is not supported by a multicomponent profile")
635+
return
618636
end
619637

620638
if not battery_supported then -- battery profiles are configured later, in power_source_attribute_list_handler
621639
profile_name = string.gsub(profile_name, "1%-", "") -- remove the "1-" in a device with 1 button ep
622640
device:try_update_metadata({profile = profile_name})
623641
end
624-
device:set_field(BUTTON_DEVICE_PROFILED, true)
642+
device:set_field(DEVICE_PROFILED, true)
625643
end
626644

627-
local function try_build_child_switch_profiles(driver, device, switch_eps, main_endpoint)
645+
local function build_child_switch_profiles(driver, device, switch_eps, main_endpoint)
628646
local num_switch_server_eps = 0
629647
local parent_child_device = false
630648
for _, ep in ipairs(switch_eps) do
@@ -658,58 +676,63 @@ local function try_build_child_switch_profiles(driver, device, switch_eps, main_
658676
device:set_field(IS_PARENT_CHILD_DEVICE, true, {persist = true})
659677
end
660678

661-
device:set_field(SWITCH_INITIALIZED, true, {persist = true})
662-
663679
-- this is needed in initialize_buttons_and_switches
664680
return num_switch_server_eps
665681
end
666682

667-
local function handle_light_switch_with_onOff_server_clusters(device, main_endpoint, num_switch_server_eps)
668-
local cluster_id = 0
669-
for _, ep in ipairs(device.endpoints) do
670-
-- main_endpoint only supports server cluster by definition of get_endpoints()
671-
if main_endpoint == ep.endpoint_id then
672-
for _, dt in ipairs(ep.device_types) do
673-
-- no device type that is not in the switch subset should be considered.
674-
if (ON_OFF_SWITCH_ID <= dt.device_type_id and dt.device_type_id <= ON_OFF_COLOR_DIMMER_SWITCH_ID) then
675-
cluster_id = math.max(cluster_id, dt.device_type_id)
676-
end
683+
local function handle_light_switch_with_onOff_server_clusters(device, main_endpoint)
684+
local cluster_id = 0
685+
for _, ep in ipairs(device.endpoints) do
686+
-- main_endpoint only supports server cluster by definition of get_endpoints()
687+
if main_endpoint == ep.endpoint_id then
688+
for _, dt in ipairs(ep.device_types) do
689+
-- no device type that is not in the switch subset should be considered.
690+
if (ON_OFF_SWITCH_ID <= dt.device_type_id and dt.device_type_id <= ON_OFF_COLOR_DIMMER_SWITCH_ID) then
691+
cluster_id = math.max(cluster_id, dt.device_type_id)
677692
end
678-
break
679693
end
694+
break
680695
end
696+
end
681697

682-
if device_type_profile_map[cluster_id] then
683-
device:try_update_metadata({profile = device_type_profile_map[cluster_id]})
684-
end
698+
if device_type_profile_map[cluster_id] then
699+
device:try_update_metadata({profile = device_type_profile_map[cluster_id]})
700+
end
685701
end
686702

687703
local function initialize_buttons_and_switches(driver, device, main_endpoint)
688704
local switch_eps = device:get_endpoints(clusters.OnOff.ID)
689705
local button_eps = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})
706+
local fan_eps = device:get_endpoints(clusters.FanControl.ID)
690707
table.sort(switch_eps)
691708
table.sort(button_eps)
709+
table.sort(fan_eps)
692710

693-
-- All button endpoints found will be added as additional components in the profile containing the main_endpoint.
694-
-- The resulting endpoint to component map is saved in the COMPONENT_TO_ENDPOINT_MAP_BUTTON field
695-
try_build_button_component_map(device, main_endpoint, button_eps)
696-
697-
-- Without support for bindings, only clusters that are implemented as server are counted. This count is handled
698-
-- while building switch child profiles
699-
local num_switch_server_eps = try_build_child_switch_profiles(driver, device, switch_eps, main_endpoint)
700-
711+
-- Button/fan endpoints will be added as additional components in the profile containing the main_endpoint.
712+
-- The resulting endpoint to component map is saved in the COMPONENT_TO_ENDPOINT_MAP_NEW_DEVICES field
701713
if #button_eps > 0 then
702-
build_button_profile(device, main_endpoint, #button_eps)
714+
build_mcd_profile(device, main_endpoint)
715+
build_component_map(device, main_endpoint, button_eps)
703716
configure_buttons(device)
704-
return
717+
elseif #fan_eps > 0 then
718+
build_mcd_profile(device, main_endpoint)
719+
build_component_map(device, main_endpoint, fan_eps)
705720
end
706721

707-
-- We do not support the Light Switch device types because they require OnOff to be implemented as 'client', which requires us to support bindings.
708-
-- However, this workaround profiles devices that claim to be Light Switches, but that break spec and implement OnOff as 'server'.
709-
-- Note: since their device type isn't supported, these devices join as a matter-thing.
710-
if num_switch_server_eps > 0 and detect_matter_thing(device) then
711-
handle_light_switch_with_onOff_server_clusters(device, main_endpoint, num_switch_server_eps)
722+
if #switch_eps > 0 then
723+
-- Without support for bindings, only clusters that are implemented as server are counted. This count is handled
724+
-- while building switch child profiles
725+
local num_switch_server_eps = build_child_switch_profiles(driver, device, switch_eps, main_endpoint)
726+
727+
-- We do not support the Light Switch device types because they require OnOff to be implemented as 'client', which requires us to support bindings.
728+
-- However, this workaround profiles devices that claim to be Light Switches, but that break spec and implement OnOff as 'server'.
729+
-- Note: since their device type isn't supported, these devices join as a matter-thing.
730+
if num_switch_server_eps > 0 and detect_matter_thing(device) then
731+
handle_light_switch_with_onOff_server_clusters(device, main_endpoint)
732+
end
712733
end
734+
735+
device:set_field(SWITCH_INITIALIZED, true, {persist = true})
713736
end
714737

715738
local function detect_bridge(device)
@@ -883,6 +906,29 @@ local function handle_set_level(driver, device, cmd)
883906
end
884907
end
885908

909+
local function set_fan_mode(driver, device, cmd)
910+
local fan_mode_id
911+
if cmd.args.fanMode == capabilities.fanMode.fanMode.low.NAME then
912+
fan_mode_id = clusters.FanControl.attributes.FanMode.LOW
913+
elseif cmd.args.fanMode == capabilities.fanMode.fanMode.medium.NAME then
914+
fan_mode_id = clusters.FanControl.attributes.FanMode.MEDIUM
915+
elseif cmd.args.fanMode == capabilities.fanMode.fanMode.high.NAME then
916+
fan_mode_id = clusters.FanControl.attributes.FanMode.HIGH
917+
elseif cmd.args.fanMode == capabilities.fanMode.fanMode.auto.NAME then
918+
fan_mode_id = clusters.FanControl.attributes.FanMode.AUTO
919+
else
920+
fan_mode_id = clusters.FanControl.attributes.FanMode.OFF
921+
end
922+
if fan_mode_id then
923+
device:send(clusters.FanControl.attributes.FanMode:write(device, device:component_to_endpoint(cmd.component), fan_mode_id))
924+
end
925+
end
926+
927+
local function set_fan_speed_percent(driver, device, cmd)
928+
local speed = math.floor(cmd.args.percent)
929+
device:send(clusters.FanControl.attributes.PercentSetting:write(device, device:component_to_endpoint(cmd.component), speed))
930+
end
931+
886932
local function handle_refresh(driver, device, cmd)
887933
--Note: no endpoint specified indicates a wildcard endpoint
888934
local req = clusters.OnOff.attributes.OnOff:read(device)
@@ -1340,6 +1386,73 @@ local function humidity_attr_handler(driver, device, ib, response)
13401386
end
13411387
end
13421388

1389+
local function fan_mode_handler(driver, device, ib, response)
1390+
if ib.data.value == clusters.FanControl.attributes.FanMode.OFF then
1391+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("off"))
1392+
elseif ib.data.value == clusters.FanControl.attributes.FanMode.LOW then
1393+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("low"))
1394+
elseif ib.data.value == clusters.FanControl.attributes.FanMode.MEDIUM then
1395+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("medium"))
1396+
elseif ib.data.value == clusters.FanControl.attributes.FanMode.HIGH then
1397+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("high"))
1398+
else
1399+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanMode.fanMode("auto"))
1400+
end
1401+
end
1402+
1403+
local function fan_mode_sequence_handler(driver, device, ib, response)
1404+
local supportedFanModes
1405+
if ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH then
1406+
supportedFanModes = {
1407+
capabilities.fanMode.fanMode.off.NAME,
1408+
capabilities.fanMode.fanMode.low.NAME,
1409+
capabilities.fanMode.fanMode.medium.NAME,
1410+
capabilities.fanMode.fanMode.high.NAME
1411+
}
1412+
elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH then
1413+
supportedFanModes = {
1414+
capabilities.fanMode.fanMode.off.NAME,
1415+
capabilities.fanMode.fanMode.low.NAME,
1416+
capabilities.fanMode.fanMode.high.NAME
1417+
}
1418+
elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_MED_HIGH_AUTO then
1419+
supportedFanModes = {
1420+
capabilities.fanMode.fanMode.off.NAME,
1421+
capabilities.fanMode.fanMode.low.NAME,
1422+
capabilities.fanMode.fanMode.medium.NAME,
1423+
capabilities.fanMode.fanMode.high.NAME,
1424+
capabilities.fanMode.fanMode.auto.NAME
1425+
}
1426+
elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_LOW_HIGH_AUTO then
1427+
supportedFanModes = {
1428+
capabilities.fanMode.fanMode.off.NAME,
1429+
capabilities.fanMode.fanMode.low.NAME,
1430+
capabilities.fanMode.fanMode.high.NAME,
1431+
capabilities.fanMode.fanMode.auto.NAME
1432+
}
1433+
elseif ib.data.value == clusters.FanControl.attributes.FanModeSequence.OFF_ON_AUTO then
1434+
supportedFanModes = {
1435+
capabilities.fanMode.fanMode.off.NAME,
1436+
capabilities.fanMode.fanMode.high.NAME,
1437+
capabilities.fanMode.fanMode.auto.NAME
1438+
}
1439+
else
1440+
supportedFanModes = {
1441+
capabilities.fanMode.fanMode.off.NAME,
1442+
capabilities.fanMode.fanMode.high.NAME
1443+
}
1444+
end
1445+
local event = capabilities.fanMode.supportedFanModes(supportedFanModes, {visibility = {displayed = false}})
1446+
device:emit_event_for_endpoint(ib.endpoint_id, event)
1447+
end
1448+
1449+
local function fan_speed_percent_attr_handler(driver, device, ib, response)
1450+
if ib.data.value == nil or ib.data.value < 0 or ib.data.value > 100 then
1451+
return
1452+
end
1453+
device:emit_event_for_endpoint(ib.endpoint_id, capabilities.fanSpeedPercent.percent(ib.data.value))
1454+
end
1455+
13431456
local matter_driver_template = {
13441457
lifecycle_handlers = {
13451458
init = device_init,
@@ -1401,6 +1514,11 @@ local matter_driver_template = {
14011514
[clusters.TemperatureMeasurement.attributes.MeasuredValue.ID] = temperature_attr_handler,
14021515
[clusters.TemperatureMeasurement.attributes.MinMeasuredValue.ID] = temp_attr_handler_factory(TEMP_MIN),
14031516
[clusters.TemperatureMeasurement.attributes.MaxMeasuredValue.ID] = temp_attr_handler_factory(TEMP_MAX),
1517+
},
1518+
[clusters.FanControl.ID] = {
1519+
[clusters.FanControl.attributes.FanModeSequence.ID] = fan_mode_sequence_handler,
1520+
[clusters.FanControl.attributes.FanMode.ID] = fan_mode_handler,
1521+
[clusters.FanControl.attributes.PercentCurrent.ID] = fan_speed_percent_attr_handler
14041522
}
14051523
},
14061524
event = {
@@ -1466,6 +1584,13 @@ local matter_driver_template = {
14661584
clusters.TemperatureMeasurement.attributes.MeasuredValue,
14671585
clusters.TemperatureMeasurement.attributes.MinMeasuredValue,
14681586
clusters.TemperatureMeasurement.attributes.MaxMeasuredValue
1587+
},
1588+
[capabilities.fanMode.ID] = {
1589+
clusters.FanControl.attributes.FanModeSequence,
1590+
clusters.FanControl.attributes.FanMode
1591+
},
1592+
[capabilities.fanSpeedPercent.ID] = {
1593+
clusters.FanControl.attributes.PercentCurrent
14691594
}
14701595
},
14711596
subscribed_events = {
@@ -1501,6 +1626,12 @@ local matter_driver_template = {
15011626
},
15021627
[capabilities.level.ID] = {
15031628
[capabilities.level.commands.setLevel.NAME] = handle_set_level
1629+
},
1630+
[capabilities.fanMode.ID] = {
1631+
[capabilities.fanMode.commands.setFanMode.NAME] = set_fan_mode
1632+
},
1633+
[capabilities.fanSpeedPercent.ID] = {
1634+
[capabilities.fanSpeedPercent.commands.setPercent.NAME] = set_fan_speed_percent,
15041635
}
15051636
},
15061637
supported_capabilities = {
@@ -1519,7 +1650,9 @@ local matter_driver_template = {
15191650
capabilities.battery,
15201651
capabilities.batteryLevel,
15211652
capabilities.temperatureMeasurement,
1522-
capabilities.relativeHumidityMeasurement
1653+
capabilities.relativeHumidityMeasurement,
1654+
capabilities.fanMode,
1655+
capabilities.fanSpeedPercent
15231656
},
15241657
sub_drivers = {
15251658
require("eve-energy"),

0 commit comments

Comments
 (0)