diff --git a/drivers/SmartThings/matter-switch/src/init.lua b/drivers/SmartThings/matter-switch/src/init.lua index b1c6a8df8b..2ece06cbbf 100644 --- a/drivers/SmartThings/matter-switch/src/init.lua +++ b/drivers/SmartThings/matter-switch/src/init.lua @@ -38,12 +38,15 @@ function SwitchLifecycleHandlers.device_added(driver, device) device:send(clusters.OnOff.attributes.OnOff:read(device)) end - -- call device init in case init is not called after added due to device caching - SwitchLifecycleHandlers.device_init(driver, device) + -- The device init event is guaranteed in FW versions 58+, so this is only needed for older hubs + if version.rpc < 10 then + -- call device init in case init is not called after added due to device caching + SwitchLifecycleHandlers.device_init(driver, device) + end end function SwitchLifecycleHandlers.do_configure(driver, device) - if device.network_type == device_lib.NETWORK_TYPE_MATTER and not switch_utils.detect_bridge(device) then + if device.network_type == device_lib.NETWORK_TYPE_MATTER then switch_cfg.set_device_control_options(device) device_cfg.match_profile(driver, device) elseif device.network_type == device_lib.NETWORK_TYPE_CHILD then @@ -54,7 +57,7 @@ function SwitchLifecycleHandlers.do_configure(driver, device) end function SwitchLifecycleHandlers.driver_switched(driver, device) - if device.network_type == device_lib.NETWORK_TYPE_MATTER and not switch_utils.detect_bridge(device) then + if device.network_type == device_lib.NETWORK_TYPE_MATTER then device_cfg.match_profile(driver, device) end end @@ -66,12 +69,16 @@ function SwitchLifecycleHandlers.info_changed(driver, device, event, args) button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) ) + if version.api >= 16 and version.rpc >= 10 then + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + camera_cfg.initialize_select_camera_capabilities(device) + end elseif device.network_type == device_lib.NETWORK_TYPE_CHILD then device:get_parent_device():subscribe() -- parent device required to send subscription requests end end - if device.network_type == device_lib.NETWORK_TYPE_MATTER and not switch_utils.detect_bridge(device) then + if device.network_type == device_lib.NETWORK_TYPE_MATTER then if device.matter_version.software ~= args.old_st_store.matter_version.software then device_cfg.match_profile(driver, device) end @@ -95,11 +102,16 @@ function SwitchLifecycleHandlers.device_init(driver, device) switch_utils.check_field_name_updates(device) device:set_component_to_endpoint_fn(switch_utils.component_to_endpoint) device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) + device:extend_device("emit_event_for_endpoint", switch_utils.emit_event_for_endpoint) if device:get_field(fields.IS_PARENT_CHILD_DEVICE) then device:set_find_child(switch_utils.find_child) end if #device:get_endpoints(clusters.PowerSource.ID, {feature_bitmap = clusters.PowerSource.types.PowerSourceFeature.BATTERY}) == 0 then - device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist = true}) + switch_utils.set_preprofiling_data(device, fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY) + end + if version.api < 16 or version.rpc < 10 or #device:get_endpoints(clusters.CameraAvStreamManagement.ID) == 0 then + switch_utils.set_preprofiling_data(device, fields.profiling_data.STATUS_LIGHT_ENABLED_PRESENT, false) + switch_utils.set_preprofiling_data(device, fields.profiling_data.STATUS_LIGHT_BRIGHTNESS_PRESENT, false) end switch_utils.handle_electrical_sensor_info(device) device:extend_device("subscribe", switch_utils.subscribe) diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua index b32b1dd55c..69753f58ed 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_handlers/attribute_handlers.lua @@ -1,13 +1,11 @@ -- Copyright © 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local camera_fields = require "sub_drivers.camera.camera_utils.fields" -local camera_utils = require "sub_drivers.camera.camera_utils.utils" local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" -local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" -local fields = require "switch_utils.fields" -local utils = require "st.utils" +local camera_fields = require "sub_drivers.camera.camera_utils.fields" +local camera_utils = require "sub_drivers.camera.camera_utils.utils" +local st_utils = require "st.utils" local CameraAttributeHandlers = {} @@ -37,7 +35,7 @@ CameraAttributeHandlers.night_vision_factory = function(attribute) end function CameraAttributeHandlers.image_rotation_handler(driver, device, ib, response) - local degrees = utils.clamp_value(ib.data.value, 0, 359) + local degrees = st_utils.clamp_value(ib.data.value, 0, 359) device:emit_event_for_endpoint(ib, capabilities.imageControl.imageRotation(degrees)) camera_utils.update_supported_attributes(device, ib, capabilities.imageControl, "imageRotation") end @@ -63,7 +61,7 @@ function CameraAttributeHandlers.volume_level_handler(driver, device, ib, respon local min_volume = device:get_field(camera_fields.MIN_VOLUME_LEVEL .. "_" .. component) or camera_fields.ABS_VOL_MIN -- Convert from [min_volume, max_volume] to [0, 100] before emitting capability local limited_range = max_volume - min_volume - local normalized_volume = utils.round((ib.data.value - min_volume) * 100.0 / limited_range) + local normalized_volume = st_utils.round((ib.data.value - min_volume) * 100.0 / limited_range) device:emit_event_for_endpoint(ib, capabilities.audioVolume.volume(normalized_volume)) end @@ -296,7 +294,7 @@ function CameraAttributeHandlers.ptz_position_handler(driver, device, ib, respon local emit_event = function(idx, value) if value ~= ptz_map[idx].current then device:emit_event_for_endpoint(ib, ptz_map[idx].attribute( - utils.clamp_value(value, ptz_map[idx].range.minimum, ptz_map[idx].range.maximum) + st_utils.clamp_value(value, ptz_map[idx].range.minimum, ptz_map[idx].range.maximum) )) end end @@ -447,31 +445,35 @@ function CameraAttributeHandlers.selected_chime_handler(driver, device, ib, resp end function CameraAttributeHandlers.camera_av_stream_management_attribute_list_handler(driver, device, ib, response) - if not ib.data.elements then return end local status_light_enabled_present, status_light_brightness_present = false, false - local attribute_ids = {} - for _, attr in ipairs(ib.data.elements) do + local status_light_attribute_ids = {} + for _, attr in ipairs(ib.data.elements or {}) do if attr.value == clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID then status_light_enabled_present = true - table.insert(attribute_ids, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID) + table.insert(status_light_attribute_ids, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID) elseif attr.value == clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID then status_light_brightness_present = true - table.insert(attribute_ids, clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + table.insert(status_light_attribute_ids, clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) end end - local component_map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} - component_map.statusLed = { - endpoint_id = ib.endpoint_id, - cluster_id = ib.cluster_id, - attribute_ids = attribute_ids, - } - device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist=true}) - camera_cfg.update_status_light_attribute_presence(device, status_light_enabled_present, status_light_brightness_present) - camera_cfg.reconcile_profile_and_capabilities(device) + if #status_light_attribute_ids > 0 then + camera_utils.update_component_to_endpoint_map(device, camera_fields.profile_components.statusLed, { + endpoint_id = ib.endpoint_id, + cluster_id = ib.cluster_id, + attribute_ids = status_light_attribute_ids, + }) + end + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + local utils = require "switch_utils.utils" + local fields = require "switch_utils.fields" + utils.set_preprofiling_data(device, fields.profiling_data.STATUS_LIGHT_ENABLED_PRESENT, status_light_enabled_present) + utils.set_preprofiling_data(device, fields.profiling_data.STATUS_LIGHT_BRIGHTNESS_PRESENT, status_light_brightness_present) + camera_cfg.reconcile_profile_and_capabilities(driver, device) end function CameraAttributeHandlers.camera_feature_map_handler(driver, device, ib, response) - camera_cfg.reconcile_profile_and_capabilities(device) + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + camera_cfg.reconcile_profile_and_capabilities(driver, device) end return CameraAttributeHandlers diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua index c2ea259aa1..175ad68c4f 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/device_configuration.lua @@ -1,195 +1,17 @@ -- Copyright © 2025 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 -local button_cfg = require("switch_utils.device_configuration").ButtonCfg local camera_fields = require "sub_drivers.camera.camera_utils.fields" local camera_utils = require "sub_drivers.camera.camera_utils.utils" local capabilities = require "st.capabilities" local clusters = require "st.matter.clusters" -local device_cfg = require "switch_utils.device_configuration" local fields = require "switch_utils.fields" local switch_utils = require "switch_utils.utils" +local st_utils = require "st.utils" local CameraDeviceConfiguration = {} -local managed_capability_map = { - { key = "webrtc", capability = capabilities.webrtc }, - { key = "ptz", capability = capabilities.mechanicalPanTiltZoom }, - { key = "zone_management", capability = capabilities.zoneManagement }, - { key = "local_media_storage", capability = capabilities.localMediaStorage }, - { key = "audio_recording", capability = capabilities.audioRecording }, - { key = "video_stream_settings", capability = capabilities.videoStreamSettings }, - { key = "camera_privacy_mode", capability = capabilities.cameraPrivacyMode }, -} - -local function get_status_light_presence(device) - return device:get_field(camera_fields.STATUS_LIGHT_ENABLED_PRESENT), - device:get_field(camera_fields.STATUS_LIGHT_BRIGHTNESS_PRESENT) -end - -local function set_status_light_presence(device, status_light_enabled_present, status_light_brightness_present) - device:set_field(camera_fields.STATUS_LIGHT_ENABLED_PRESENT, status_light_enabled_present == true, { persist = true }) - device:set_field(camera_fields.STATUS_LIGHT_BRIGHTNESS_PRESENT, status_light_brightness_present == true, { persist = true }) -end - -local function build_webrtc_supported_features() - return { - bundle = true, - order = "audio/video", - audio = "sendrecv", - video = "recvonly", - turnSource = "player", - supportTrickleICE = true - } -end - -local function build_ptz_supported_attributes(device) - local supported_attributes = {} - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPAN) then - table.insert(supported_attributes, "pan") - table.insert(supported_attributes, "panRange") - end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MTILT) then - table.insert(supported_attributes, "tilt") - table.insert(supported_attributes, "tiltRange") - end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MZOOM) then - table.insert(supported_attributes, "zoom") - table.insert(supported_attributes, "zoomRange") - end - if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPRESETS) then - table.insert(supported_attributes, "presets") - table.insert(supported_attributes, "maxPresets") - end - return supported_attributes -end - -local function build_zone_management_supported_features(device) - local supported_features = { "triggerAugmentation" } - if camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) then - table.insert(supported_features, "perZoneSensitivity") - end - return supported_features -end - -local function build_local_media_storage_supported_attributes(device) - local supported_attributes = {} - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then - table.insert(supported_attributes, "localVideoRecording") - end - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.SNAPSHOT) then - table.insert(supported_attributes, "localSnapshotRecording") - end - return supported_attributes -end - -local function build_video_stream_settings_supported_features(device) - local supported_features = {} - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then - table.insert(supported_features, "liveStreaming") - table.insert(supported_features, "clipRecording") - table.insert(supported_features, "perStreamViewports") - end - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.WATERMARK) then - table.insert(supported_features, "watermark") - end - if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY) then - table.insert(supported_features, "onScreenDisplay") - end - return supported_features -end - -local function build_camera_privacy_supported_attributes() - return { "softRecordingPrivacyMode", "softLivestreamPrivacyMode" } -end - -local function build_camera_privacy_supported_commands() - return { "setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode" } -end - -local function capabilities_needing_reinit(device) - local main = camera_fields.profile_components.main - - local capabilities_to_reinit = {} - - local function state_differs(capability, attribute_name, expected) - local current = device:get_latest_state(main, capability.ID, attribute_name) - return not switch_utils.deep_equals(current, expected, { ignore_functions = true }) - end - - if device:supports_capability(capabilities.webrtc) and - state_differs(capabilities.webrtc, capabilities.webrtc.supportedFeatures.NAME, build_webrtc_supported_features()) then - capabilities_to_reinit.webrtc = true - end - - if device:supports_capability(capabilities.mechanicalPanTiltZoom) and - state_differs(capabilities.mechanicalPanTiltZoom, capabilities.mechanicalPanTiltZoom.supportedAttributes.NAME, build_ptz_supported_attributes(device)) then - capabilities_to_reinit.ptz = true - end - - if device:supports_capability(capabilities.zoneManagement) and - state_differs(capabilities.zoneManagement, capabilities.zoneManagement.supportedFeatures.NAME, build_zone_management_supported_features(device)) then - capabilities_to_reinit.zone_management = true - end - - if device:supports_capability(capabilities.localMediaStorage) and - state_differs(capabilities.localMediaStorage, capabilities.localMediaStorage.supportedAttributes.NAME, build_local_media_storage_supported_attributes(device)) then - capabilities_to_reinit.local_media_storage = true - end - - if device:supports_capability(capabilities.audioRecording) then - local audio_enabled_state = device:get_latest_state(main, capabilities.audioRecording.ID, capabilities.audioRecording.audioRecording.NAME) - if audio_enabled_state == nil then - capabilities_to_reinit.audio_recording = true - end - end - - if device:supports_capability(capabilities.videoStreamSettings) and - state_differs(capabilities.videoStreamSettings, capabilities.videoStreamSettings.supportedFeatures.NAME, build_video_stream_settings_supported_features(device)) then - capabilities_to_reinit.video_stream_settings = true - end - - if device:supports_capability(capabilities.cameraPrivacyMode) and - (state_differs(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedAttributes.NAME, build_camera_privacy_supported_attributes()) or - state_differs(capabilities.cameraPrivacyMode, capabilities.cameraPrivacyMode.supportedCommands.NAME, build_camera_privacy_supported_commands())) then - capabilities_to_reinit.camera_privacy_mode = true - end - - return capabilities_to_reinit -end - -function CameraDeviceConfiguration.create_child_devices(driver, device) - local num_floodlight_eps = 0 - local parent_child_device = false - for _, ep in ipairs(device.endpoints or {}) do - if device:supports_server_cluster(clusters.OnOff.ID, ep.endpoint_id) then - local child_profile = device_cfg.SwitchCfg.assign_profile_for_onoff_ep(device, ep.endpoint_id) - if child_profile then - num_floodlight_eps = num_floodlight_eps + 1 - local name = string.format("%s %d", "Floodlight", num_floodlight_eps) - driver:try_create_device( - { - type = "EDGE_CHILD", - label = name, - profile = child_profile, - parent_device_id = device.id, - parent_assigned_child_key = string.format("%d", ep.endpoint_id), - vendor_provided_label = name - } - ) - parent_child_device = true - end - end - end - if parent_child_device then - device:set_field(fields.IS_PARENT_CHILD_DEVICE, true, {persist = true}) - device:set_find_child(switch_utils.find_child) - end -end - -function CameraDeviceConfiguration.match_profile(device) - local status_light_enabled_present, status_light_brightness_present = get_status_light_presence(device) - local profile_update_requested = false +function CameraDeviceConfiguration.assign_profile_for_camera_ep(device, camera_ep_id) local optional_supported_component_capabilities = {} local main_component_capabilities = {} local status_led_component_capabilities = {} @@ -201,9 +23,8 @@ function CameraDeviceConfiguration.match_profile(device) return cluster.cluster_type == "SERVER" or cluster.cluster_type == "BOTH" end - local camera_endpoints = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.CAMERA) - if #camera_endpoints > 0 then - local camera_ep = switch_utils.get_endpoint_info(device, camera_endpoints[1]) + if camera_ep_id then + local camera_ep = switch_utils.get_endpoint_info(device, camera_ep_id) for _, ep_cluster in pairs(camera_ep.clusters or {}) do if ep_cluster.cluster_id == clusters.CameraAvStreamManagement.ID and has_server_cluster_type(ep_cluster) then local clus_has_feature = function(feature_bitmap) @@ -271,10 +92,10 @@ function CameraDeviceConfiguration.match_profile(device) if #doorbell_endpoints > 0 then table.insert(doorbell_component_capabilities, capabilities.button.ID) end - if status_light_enabled_present then + if device:get_field(fields.profiling_data.STATUS_LIGHT_ENABLED_PRESENT) then table.insert(status_led_component_capabilities, capabilities.switch.ID) end - if status_light_brightness_present then + if device:get_field(fields.profiling_data.STATUS_LIGHT_BRIGHTNESS_PRESENT) then table.insert(status_led_component_capabilities, capabilities.mode.ID) end @@ -293,170 +114,237 @@ function CameraDeviceConfiguration.match_profile(device) end if camera_utils.optional_capabilities_list_changed(optional_supported_component_capabilities, device.profile.components) then - profile_update_requested = true - device:try_update_metadata({profile = "camera", optional_component_capabilities = optional_supported_component_capabilities}) - if #doorbell_endpoints > 0 then - CameraDeviceConfiguration.update_doorbell_component_map(device, doorbell_endpoints[1]) - button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})) - end + CameraDeviceConfiguration.update_camera_component_map(device) + return "camera", optional_supported_component_capabilities end +end - return profile_update_requested +local function build_webrtc_supported_features() + return { + bundle = true, + order = "audio/video", + audio = "sendrecv", + video = "recvonly", + turnSource = "player", + supportTrickleICE = true + } end -local function init_webrtc(device) - if device:supports_capability(capabilities.webrtc) then - local transport_provider_ep_ids = device:get_endpoints(clusters.WebRTCTransportProvider.ID) - device:emit_event_for_endpoint(transport_provider_ep_ids[1], capabilities.webrtc.supportedFeatures(build_webrtc_supported_features())) +local function build_ptz_supported_attributes(device) + local supported_attributes = {} + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPAN) then + table.insert(supported_attributes, "pan") + table.insert(supported_attributes, "panRange") + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MTILT) then + table.insert(supported_attributes, "tilt") + table.insert(supported_attributes, "tiltRange") + end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MZOOM) then + table.insert(supported_attributes, "zoom") + table.insert(supported_attributes, "zoomRange") end + if camera_utils.feature_supported(device, clusters.CameraAvSettingsUserLevelManagement.ID, clusters.CameraAvSettingsUserLevelManagement.types.Feature.MPRESETS) then + table.insert(supported_attributes, "presets") + table.insert(supported_attributes, "maxPresets") + end + return supported_attributes end -local function init_ptz(device) - if device:supports_capability(capabilities.mechanicalPanTiltZoom) then - local av_settings_ep_ids = device:get_endpoints(clusters.CameraAvSettingsUserLevelManagement.ID) - device:emit_event_for_endpoint(av_settings_ep_ids[1], capabilities.mechanicalPanTiltZoom.supportedAttributes(build_ptz_supported_attributes(device))) +local function build_zone_management_supported_features(device) + local supported_features = { "triggerAugmentation" } + if camera_utils.feature_supported(device, clusters.ZoneManagement.ID, clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY) then + table.insert(supported_features, "perZoneSensitivity") end + return supported_features end -local function init_zone_management(device) - if device:supports_capability(capabilities.zoneManagement) then - local zone_management_ep_ids = device:get_endpoints(clusters.ZoneManagement.ID) - device:emit_event_for_endpoint(zone_management_ep_ids[1], capabilities.zoneManagement.supportedFeatures(build_zone_management_supported_features(device))) +local function build_local_media_storage_supported_attributes(device) + local supported_attributes = {} + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then + table.insert(supported_attributes, "localVideoRecording") end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.SNAPSHOT) then + table.insert(supported_attributes, "localSnapshotRecording") + end + return supported_attributes end -local function init_local_media_storage(device) - if device:supports_capability(capabilities.localMediaStorage) then - local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.localMediaStorage.supportedAttributes(build_local_media_storage_supported_attributes(device))) +local function build_video_stream_settings_supported_features(device) + local supported_features = {} + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then + table.insert(supported_features, "liveStreaming") + table.insert(supported_features, "clipRecording") + table.insert(supported_features, "perStreamViewports") end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.WATERMARK) then + table.insert(supported_features, "watermark") + end + if camera_utils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY) then + table.insert(supported_features, "onScreenDisplay") + end + return supported_features end -local function init_audio_recording(device) - if device:supports_capability(capabilities.audioRecording) then - local audio_enabled_state = device:get_latest_state( - camera_fields.profile_components.main, capabilities.audioRecording.ID, capabilities.audioRecording.audioRecording.NAME - ) - if audio_enabled_state == nil then - -- Initialize with enabled default if state is unset - local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.audioRecording.audioRecording("enabled")) - end - end +local function build_camera_privacy_supported_attributes() + return { "softRecordingPrivacyMode", "softLivestreamPrivacyMode" } end -local function init_video_stream_settings(device) - if device:supports_capability(capabilities.videoStreamSettings) then - local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.videoStreamSettings.supportedFeatures(build_video_stream_settings_supported_features(device))) - end +local function build_camera_privacy_supported_commands() + return { "setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode" } end -local function init_camera_privacy_mode(device) - if device:supports_capability(capabilities.cameraPrivacyMode) then - local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedAttributes(build_camera_privacy_supported_attributes())) - device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedCommands(build_camera_privacy_supported_commands())) - end +local function build_audio_recording() + return "enabled" end -function CameraDeviceConfiguration.initialize_camera_capabilities(device) - init_webrtc(device) - init_ptz(device) - init_zone_management(device) - init_local_media_storage(device) - init_audio_recording(device) - init_video_stream_settings(device) - init_camera_privacy_mode(device) +local function init_webrtc(device, supported_features) + local transport_provider_ep_ids = device:get_endpoints(clusters.WebRTCTransportProvider.ID) + device:emit_event_for_endpoint(transport_provider_ep_ids[1], capabilities.webrtc.supportedFeatures(supported_features)) end -function CameraDeviceConfiguration.initialize_camera_capabilities_and_subscriptions(device) - CameraDeviceConfiguration.initialize_camera_capabilities(device) - device:subscribe() - if #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.DOORBELL) > 0 then - button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})) +local function init_ptz(device, supported_attributes) + if device:supports_capability(capabilities.mechanicalPanTiltZoom) then + local av_settings_ep_ids = device:get_endpoints(clusters.CameraAvSettingsUserLevelManagement.ID) + device:emit_event_for_endpoint(av_settings_ep_ids[1], capabilities.mechanicalPanTiltZoom.supportedAttributes(supported_attributes)) end end -local function initialize_selected_camera_capabilities(device, capabilities_to_reinit) - local reinit_targets = capabilities_to_reinit or {} +local function init_zone_management(device, supported_features) + local zone_management_ep_ids = device:get_endpoints(clusters.ZoneManagement.ID) + device:emit_event_for_endpoint(zone_management_ep_ids[1], capabilities.zoneManagement.supportedFeatures(supported_features)) +end - if reinit_targets.webrtc then - init_webrtc(device) - end - if reinit_targets.ptz then - init_ptz(device) - end - if reinit_targets.zone_management then - init_zone_management(device) - end - if reinit_targets.local_media_storage then - init_local_media_storage(device) - end - if reinit_targets.audio_recording then - init_audio_recording(device) - end - if reinit_targets.video_stream_settings then - init_video_stream_settings(device) - end - if reinit_targets.camera_privacy_mode then - init_camera_privacy_mode(device) - end +local function init_local_media_storage(device, supported_attributes) + local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.localMediaStorage.supportedAttributes(supported_attributes)) end -local function profile_capability_set(profile) - local capability_set = {} - for _, component in pairs((profile or {}).components or {}) do - for _, capability in ipairs(component.capabilities or {}) do - if capability.id ~= nil then - capability_set[capability.id] = true - end - end +local function init_audio_recording(device, initial_audio_recording_state) + local audio_enabled_state = device:get_latest_state( + camera_fields.profile_components.main, capabilities.audioRecording.ID, capabilities.audioRecording.audioRecording.NAME + ) + if audio_enabled_state == nil then + -- Initialize with enabled default if state is unset + local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.audioRecording.audioRecording(initial_audio_recording_state)) end - return capability_set end -local function changed_capabilities_from_profiles(old_profile, new_profile) - local flags = {} - local old_set = profile_capability_set(old_profile) - local new_set = profile_capability_set(new_profile) +local function init_video_stream_settings(device, supported_features) + local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.videoStreamSettings.supportedFeatures(supported_features)) +end + +local function init_camera_privacy_mode_attributes(device, supported_attributes) + local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedAttributes(supported_attributes)) +end + +local function init_camera_privacy_mode_commands(device, supported_commands) + local av_stream_management_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + device:emit_event_for_endpoint(av_stream_management_ep_ids[1], capabilities.cameraPrivacyMode.supportedCommands(supported_commands)) +end + +local ordered_managed_capabilities = { + capabilities.webrtc, + capabilities.mechanicalPanTiltZoom, + capabilities.zoneManagement, + capabilities.localMediaStorage, + capabilities.audioRecording, + capabilities.videoStreamSettings, + capabilities.cameraPrivacyMode +} + +local managed_capabilities = { + [capabilities.webrtc] = { + [capabilities.webrtc.supportedFeatures] = { init_webrtc, build_webrtc_supported_features} + }, + [capabilities.mechanicalPanTiltZoom] = { + [capabilities.mechanicalPanTiltZoom.supportedAttributes] = { init_ptz, build_ptz_supported_attributes} + }, + [capabilities.zoneManagement] = { + [capabilities.zoneManagement.supportedFeatures] = { init_zone_management, build_zone_management_supported_features} + }, + [capabilities.localMediaStorage] = { + [capabilities.localMediaStorage.supportedAttributes] = { init_local_media_storage, build_local_media_storage_supported_attributes} + }, + [capabilities.audioRecording] = { + [capabilities.audioRecording.audioRecording] = { init_audio_recording, build_audio_recording} + }, + [capabilities.videoStreamSettings] = { + [capabilities.videoStreamSettings.supportedFeatures] = { init_video_stream_settings, build_video_stream_settings_supported_features} + }, + [capabilities.cameraPrivacyMode] = { + [capabilities.cameraPrivacyMode.supportedAttributes] = { init_camera_privacy_mode_attributes, build_camera_privacy_supported_attributes}, + [capabilities.cameraPrivacyMode.supportedCommands] = { init_camera_privacy_mode_commands, build_camera_privacy_supported_commands} + } +} - for _, managed in ipairs(managed_capability_map) do - local id = managed.capability.ID - if old_set[id] ~= new_set[id] and new_set[id] == true then - flags[managed.key] = true +function CameraDeviceConfiguration.initialize_select_camera_capabilities(device) + local function should_init(capability, attribute, expected) + if device:supports_capability(capability) then + local current = st_utils.deep_copy(device:get_latest_state(camera_fields.profile_components.main, capability.ID, attribute.NAME, {})) + return not switch_utils.deep_equals(current, expected) end + return false end - return flags + for _, capability in ipairs(ordered_managed_capabilities) do + for attribute, functions in pairs(managed_capabilities[capability]) do + local init_function = functions[1] + local build_function = functions[2] + local expected = build_function(device) + if should_init(capability, attribute, expected) then + init_function(device, expected) + end + end + end end -function CameraDeviceConfiguration.reconcile_profile_and_capabilities(device) - local profile_update_requested = CameraDeviceConfiguration.match_profile(device) +function CameraDeviceConfiguration.reconcile_profile_and_capabilities(driver, device) + local device_cfg = require "switch_utils.device_configuration".DeviceCfg + local profile_update_requested = device_cfg.match_profile(driver, device) if not profile_update_requested then - local capabilities_to_reinit = capabilities_needing_reinit(device) - initialize_selected_camera_capabilities(device, capabilities_to_reinit) + CameraDeviceConfiguration.initialize_select_camera_capabilities(device) end return profile_update_requested end -function CameraDeviceConfiguration.update_status_light_attribute_presence(device, status_light_enabled_present, status_light_brightness_present) - set_status_light_presence(device, status_light_enabled_present, status_light_brightness_present) -end - -function CameraDeviceConfiguration.reinitialize_changed_camera_capabilities_and_subscriptions(device, old_profile, new_profile) - local changed_capabilities = changed_capabilities_from_profiles(old_profile, new_profile) - initialize_selected_camera_capabilities(device, changed_capabilities) - device:subscribe() - if #switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.DOORBELL) > 0 then - button_cfg.configure_buttons(device, device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH})) +function CameraDeviceConfiguration.update_camera_component_map(device) + -- An assumption here: there is only 1 CameraAvStreamManagement cluster on the device (which is all our profile supports) + local audio_camera_av_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID, {feature_bitmap=clusters.CameraAvStreamManagement.types.Feature.AUDIO}) + if #audio_camera_av_ep_ids > 0 then + camera_utils.update_component_to_endpoint_map(device, camera_fields.profile_components.microphone, { + endpoint_id = audio_camera_av_ep_ids[1], + cluster_id = clusters.CameraAvStreamManagement.ID, + attribute_ids = { + clusters.CameraAvStreamManagement.attributes.MicrophoneMuted.ID, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel.ID, + clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel.ID, + clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel.ID, + }, + }) + end + + local video_camera_av_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID, {feature_bitmap=clusters.CameraAvStreamManagement.types.Feature.VIDEO}) + if #video_camera_av_ep_ids > 0 then + camera_utils.update_component_to_endpoint_map(device, camera_fields.profile_components.speaker, { + endpoint_id = video_camera_av_ep_ids[1], + cluster_id = clusters.CameraAvStreamManagement.ID, + attribute_ids = { + clusters.CameraAvStreamManagement.attributes.SpeakerMuted.ID, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel.ID, + clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel.ID, + clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel.ID, + }, + }) end -end -function CameraDeviceConfiguration.update_doorbell_component_map(device, ep) - local component_map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} - component_map.doorbell = ep - device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) + local doorbell_endpoints = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.DOORBELL) + if #doorbell_endpoints > 0 then + camera_utils.update_component_to_endpoint_map(device, camera_fields.profile_components.doorbell, doorbell_endpoints[1]) + end end return CameraDeviceConfiguration diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/subscriptions.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/subscriptions.lua new file mode 100644 index 0000000000..3f201a7da0 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/subscriptions.lua @@ -0,0 +1,106 @@ +-- Copyright © 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local clusters = require "st.matter.clusters" +local capabilities = require "st.capabilities" +local camera_fields = require "sub_drivers.camera.camera_utils.fields" + +local SubscriptionMap = { + subscribed_attributes = { + [capabilities.audioMute.ID] = { + clusters.CameraAvStreamManagement.attributes.SpeakerMuted, + clusters.CameraAvStreamManagement.attributes.MicrophoneMuted + }, + [capabilities.audioVolume.ID] = { + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, + clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel, + clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel + }, + [capabilities.cameraPrivacyMode.ID] = { + clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled, + clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled, + clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn + }, + [capabilities.cameraViewportSettings.ID] = { + clusters.CameraAvStreamManagement.attributes.MinViewportResolution, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams, + clusters.CameraAvStreamManagement.attributes.Viewport + }, + [capabilities.hdr.ID] = { + clusters.CameraAvStreamManagement.attributes.HDRModeEnabled, + clusters.CameraAvStreamManagement.attributes.ImageRotation + }, + [capabilities.imageControl.ID] = { + clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal, + clusters.CameraAvStreamManagement.attributes.ImageFlipVertical + }, + [capabilities.localMediaStorage.ID] = { + clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled, + clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled + }, + [capabilities.mechanicalPanTiltZoom.ID] = { + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin + }, + [capabilities.mode.ID] = { + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness + }, + [capabilities.nightVision.ID] = { + clusters.CameraAvStreamManagement.attributes.NightVision, + clusters.CameraAvStreamManagement.attributes.NightVisionIllum + }, + [capabilities.sounds.ID] = { + clusters.Chime.attributes.InstalledChimeSounds, + clusters.Chime.attributes.SelectedChime + }, + [capabilities.switch.ID] = { + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + }, + [capabilities.videoStreamSettings.ID] = { + clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, + clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams + }, + [capabilities.webrtc.ID] = { + clusters.CameraAvStreamManagement.attributes.TwoWayTalkSupport + }, + [capabilities.zoneManagement.ID] = { + clusters.ZoneManagement.attributes.MaxZones, + clusters.ZoneManagement.attributes.Zones, + clusters.ZoneManagement.attributes.Triggers, + clusters.ZoneManagement.attributes.SensitivityMax, + clusters.ZoneManagement.attributes.Sensitivity + }, + }, + subscribed_events = { + [capabilities.zoneManagement.ID] = { + clusters.ZoneManagement.events.ZoneTriggered, + clusters.ZoneManagement.events.ZoneStopped + } + }, + conditional_subscriptions = { + [function(device) return #device:get_endpoints(clusters.CameraAvStreamManagement.ID) > 0 end] = { + clusters.CameraAvStreamManagement.attributes.AttributeList, + camera_fields.CameraAVSMFeatureMapAttr + }, + [function(device) return #device:get_endpoints(clusters.CameraAvSettingsUserLevelManagement.ID) > 0 end] = { + camera_fields.CameraAVSULMFeatureMapAttr + }, + [function(device) return #device:get_endpoints(clusters.ZoneManagement.ID) > 0 end] = { + camera_fields.ZoneManagementFeatureMapAttr, + } + } +} + +return SubscriptionMap diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua index f3b92aa3b6..aef9fd7eba 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/camera_utils/utils.lua @@ -3,57 +3,10 @@ local camera_fields = require "sub_drivers.camera.camera_utils.fields" local capabilities = require "st.capabilities" -local clusters = require "st.matter.clusters" -local fields = require "switch_utils.fields" local switch_utils = require "switch_utils.utils" -local cluster_base = require "st.matter.cluster_base" local CameraUtils = {} -function CameraUtils.component_to_endpoint(device, component) - local camera_eps = device:get_endpoints(clusters.CameraAvStreamManagement.ID) - table.sort(camera_eps) - for _, ep in ipairs(camera_eps or {}) do - if ep ~= 0 then -- 0 is the matter RootNode endpoint - return ep - end - end - return nil -end - -function CameraUtils.update_camera_component_map(device) - local camera_av_ep_ids = device:get_endpoints(clusters.CameraAvStreamManagement.ID) - if #camera_av_ep_ids > 0 then - -- An assumption here: there is only 1 CameraAvStreamManagement cluster on the device (which is all our profile supports) - local component_map = {} - if CameraUtils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.AUDIO) then - component_map.microphone = { - endpoint_id = camera_av_ep_ids[1], - cluster_id = clusters.CameraAvStreamManagement.ID, - attribute_ids = { - clusters.CameraAvStreamManagement.attributes.MicrophoneMuted.ID, - clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel.ID, - clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel.ID, - clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel.ID, - }, - } - end - if CameraUtils.feature_supported(device, clusters.CameraAvStreamManagement.ID, clusters.CameraAvStreamManagement.types.Feature.VIDEO) then - component_map.speaker = { - endpoint_id = camera_av_ep_ids[1], - cluster_id = clusters.CameraAvStreamManagement.ID, - attribute_ids = { - clusters.CameraAvStreamManagement.attributes.SpeakerMuted.ID, - clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel.ID, - clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel.ID, - clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel.ID, - }, - } - end - device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_map, {persist = true}) - end -end - function CameraUtils.get_ptz_map(device) local mechanicalPanTiltZoom = capabilities.mechanicalPanTiltZoom local ptz_map = { @@ -181,162 +134,11 @@ function CameraUtils.optional_capabilities_list_changed(new_component_capability return false end -function CameraUtils.subscribe(device) - local camera_subscribed_attributes = { - [capabilities.hdr.ID] = { - clusters.CameraAvStreamManagement.attributes.HDRModeEnabled, - clusters.CameraAvStreamManagement.attributes.ImageRotation - }, - [capabilities.nightVision.ID] = { - clusters.CameraAvStreamManagement.attributes.NightVision, - clusters.CameraAvStreamManagement.attributes.NightVisionIllum - }, - [capabilities.imageControl.ID] = { - clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal, - clusters.CameraAvStreamManagement.attributes.ImageFlipVertical - }, - [capabilities.cameraPrivacyMode.ID] = { - clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled, - clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled, - clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn - }, - [capabilities.webrtc.ID] = { - clusters.CameraAvStreamManagement.attributes.TwoWayTalkSupport - }, - [capabilities.mechanicalPanTiltZoom.ID] = { - clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition, - clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets, - clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets, - clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax, - clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax, - clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin, - clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, - clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin - }, - [capabilities.audioMute.ID] = { - clusters.CameraAvStreamManagement.attributes.SpeakerMuted, - clusters.CameraAvStreamManagement.attributes.MicrophoneMuted - }, - [capabilities.audioVolume.ID] = { - clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, - clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel, - clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel, - clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel, - clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel, - clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel - }, - [capabilities.mode.ID] = { - clusters.CameraAvStreamManagement.attributes.StatusLightBrightness - }, - [capabilities.switch.ID] = { - clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, - clusters.OnOff.attributes.OnOff - }, - [capabilities.videoStreamSettings.ID] = { - clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, - clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, - clusters.CameraAvStreamManagement.attributes.VideoSensorParams, - clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, - clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams - }, - [capabilities.zoneManagement.ID] = { - clusters.ZoneManagement.attributes.MaxZones, - clusters.ZoneManagement.attributes.Zones, - clusters.ZoneManagement.attributes.Triggers, - clusters.ZoneManagement.attributes.SensitivityMax, - clusters.ZoneManagement.attributes.Sensitivity - }, - [capabilities.sounds.ID] = { - clusters.Chime.attributes.InstalledChimeSounds, - clusters.Chime.attributes.SelectedChime - }, - [capabilities.localMediaStorage.ID] = { - clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled, - clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled - }, - [capabilities.cameraViewportSettings.ID] = { - clusters.CameraAvStreamManagement.attributes.MinViewportResolution, - clusters.CameraAvStreamManagement.attributes.VideoSensorParams, - clusters.CameraAvStreamManagement.attributes.Viewport - }, - [capabilities.motionSensor.ID] = { - clusters.OccupancySensing.attributes.Occupancy - }, - [capabilities.switchLevel.ID] = { - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - }, - [capabilities.colorControl.ID] = { - clusters.ColorControl.attributes.ColorMode, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, - }, - [capabilities.colorTemperature.ID] = { - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - }, - } - - local camera_subscribed_events = { - [capabilities.zoneManagement.ID] = { - clusters.ZoneManagement.events.ZoneTriggered, - clusters.ZoneManagement.events.ZoneStopped - }, - [capabilities.button.ID] = { - clusters.Switch.events.InitialPress, - clusters.Switch.events.LongPress, - clusters.Switch.events.ShortRelease, - clusters.Switch.events.MultiPressComplete - } - } - - local im = require "st.matter.interaction_model" - - local subscribe_request = im.InteractionRequest(im.InteractionRequest.RequestType.SUBSCRIBE, {}) - local devices_seen, capabilities_seen, attributes_seen, events_seen = {}, {}, {}, {} - local additional_attributes = {} - - if #device:get_endpoints(clusters.CameraAvStreamManagement.ID) > 0 then - table.insert(additional_attributes, clusters.CameraAvStreamManagement.attributes.AttributeList) - table.insert(additional_attributes, camera_fields.CameraAVSMFeatureMapAttr) - end - - if #device:get_endpoints(clusters.CameraAvSettingsUserLevelManagement.ID) > 0 then - table.insert(additional_attributes, camera_fields.CameraAVSULMFeatureMapAttr) - end - - if #device:get_endpoints(clusters.ZoneManagement.ID) > 0 then - table.insert(additional_attributes, camera_fields.ZoneManagementFeatureMapAttr) - end - - for _, endpoint_info in ipairs(device.endpoints) do - local checked_device = switch_utils.find_child(device, endpoint_info.endpoint_id) or device - if not devices_seen[checked_device.id] then - switch_utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, events_seen, - camera_subscribed_attributes, camera_subscribed_events - ) - devices_seen[checked_device.id] = true -- only loop through any device once - end - end - - for _, attr in ipairs(additional_attributes) do - local cluster_id = attr.cluster or attr._cluster.ID - local attr_id = attr.ID or attr.attribute - if not attributes_seen[cluster_id] or not attributes_seen[cluster_id][attr_id] then - local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) - subscribe_request:with_info_block(ib) - attributes_seen[cluster_id] = attributes_seen[cluster_id] or {} - attributes_seen[cluster_id][attr_id] = ib - end - end - - if #subscribe_request.info_blocks > 0 then - device:send(subscribe_request) - end +function CameraUtils.update_component_to_endpoint_map(device, component, endpoint_mapping) + local fields = require "switch_utils.fields" + local component_endpoint_map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} + component_endpoint_map[component] = endpoint_mapping + device:set_field(fields.COMPONENT_TO_ENDPOINT_MAP, component_endpoint_map, { persist = true }) end return CameraUtils diff --git a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua index a72aa0b234..d39fb349a3 100644 --- a/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua +++ b/drivers/SmartThings/matter-switch/src/sub_drivers/camera/init.lua @@ -5,69 +5,15 @@ -- Matter Camera Sub Driver ------------------------------------------------------------------------------------- -local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers" -local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" -local camera_fields = require "sub_drivers.camera.camera_utils.fields" -local camera_utils = require "sub_drivers.camera.camera_utils.utils" local capabilities = require "st.capabilities" -local capability_handlers = require "sub_drivers.camera.camera_handlers.capability_handlers" local clusters = require "st.matter.clusters" +local camera_fields = require "sub_drivers.camera.camera_utils.fields" +local attribute_handlers = require "sub_drivers.camera.camera_handlers.attribute_handlers" +local capability_handlers = require "sub_drivers.camera.camera_handlers.capability_handlers" local event_handlers = require "sub_drivers.camera.camera_handlers.event_handlers" -local fields = require "switch_utils.fields" -local switch_utils = require "switch_utils.utils" - -local CameraLifecycleHandlers = {} - -function CameraLifecycleHandlers.device_init(driver, device) - device:set_component_to_endpoint_fn(camera_utils.component_to_endpoint) - device:set_endpoint_to_component_fn(switch_utils.endpoint_to_component) - device:extend_device("emit_event_for_endpoint", switch_utils.emit_event_for_endpoint) - if device:get_field(fields.IS_PARENT_CHILD_DEVICE) then - device:set_find_child(switch_utils.find_child) - end - device:extend_device("subscribe", camera_utils.subscribe) - device:subscribe() -end - -function CameraLifecycleHandlers.do_configure(driver, device) - camera_utils.update_camera_component_map(device) - if #device:get_endpoints(clusters.CameraAvStreamManagement.ID) == 0 then - camera_cfg.match_profile(device) - end - camera_cfg.create_child_devices(driver, device) - camera_cfg.initialize_camera_capabilities(device) -end - -function CameraLifecycleHandlers.driver_switched(driver, device) - camera_utils.update_camera_component_map(device) - if #device:get_endpoints(clusters.CameraAvStreamManagement.ID) == 0 then - camera_cfg.match_profile(device) - end -end - -function CameraLifecycleHandlers.info_changed(driver, device, event, args) - local software_version_changed = device.matter_version ~= nil and args.old_st_store.matter_version ~= nil and - device.matter_version.software ~= args.old_st_store.matter_version.software - local profile_changed = not switch_utils.deep_equals(device.profile, args.old_st_store.profile, { ignore_functions = true }) - - if software_version_changed then - camera_cfg.reconcile_profile_and_capabilities(device) - elseif profile_changed then - camera_cfg.reinitialize_changed_camera_capabilities_and_subscriptions(device, args.old_st_store.profile, device.profile) - end -end - -function CameraLifecycleHandlers.added() end local camera_handler = { NAME = "Camera Handler", - lifecycle_handlers = { - init = CameraLifecycleHandlers.device_init, - infoChanged = CameraLifecycleHandlers.info_changed, - doConfigure = CameraLifecycleHandlers.do_configure, - driverSwitched = CameraLifecycleHandlers.driver_switched, - added = CameraLifecycleHandlers.added - }, matter_handlers = { attr = { [clusters.CameraAvStreamManagement.ID] = { diff --git a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua index b16610cf25..0ceeef2cec 100644 --- a/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua +++ b/drivers/SmartThings/matter-switch/src/switch_handlers/attribute_handlers.lua @@ -321,6 +321,10 @@ function AttributeHandlers.available_endpoints_handler(driver, device, ib, respo return end local set_topology_eps = device:get_field(fields.ELECTRICAL_SENSOR_EPS) + if set_topology_eps == nil then + device.log.warn("Received an AvailableEndpoints response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with SET feature. Ignoring this response.") + return + end for i, set_ep_info in pairs(set_topology_eps or {}) do if ib.endpoint_id == set_ep_info.endpoint_id then -- since EP response is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table @@ -336,7 +340,7 @@ function AttributeHandlers.available_endpoints_handler(driver, device, ib, respo end end if #set_topology_eps == 0 then -- in other words, all AvailableEndpoints attribute responses have been handled - device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.SET_TOPOLOGY, {persist=true}) + switch_utils.set_preprofiling_data(device, fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.SET_TOPOLOGY) device_cfg.match_profile(driver, device) end end @@ -350,6 +354,10 @@ function AttributeHandlers.parts_list_handler(driver, device, ib, response) return end local tree_topology_eps = device:get_field(fields.ELECTRICAL_SENSOR_EPS) + if tree_topology_eps == nil then + device.log.warn("Received a PartsList response but no Electrical Sensor endpoints have been identified as supporting the Power Topology cluster with SET feature. Ignoring this response.") + return + end for i, tree_ep_info in pairs(tree_topology_eps or {}) do if ib.endpoint_id == tree_ep_info.endpoint_id then -- since EP response is being handled here, remove it from the ELECTRICAL_SENSOR_EPS table @@ -365,7 +373,7 @@ function AttributeHandlers.parts_list_handler(driver, device, ib, response) end end if #tree_topology_eps == 0 then -- in other words, all PartsList attribute responses for TREE Electrical Sensor EPs have been handled - device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.TREE_TOPOLOGY, {persist=true}) + switch_utils.set_preprofiling_data(device, fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.TREE_TOPOLOGY) device_cfg.match_profile(driver, device) end end @@ -390,18 +398,20 @@ function AttributeHandlers.bat_charge_level_handler(driver, device, ib, response end function AttributeHandlers.power_source_attribute_list_handler(driver, device, ib, response) - local previous_battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) - device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist=true}) + local latest_battery_support = device:get_field(fields.profiling_data.BATTERY_SUPPORT) for _, attr in ipairs(ib.data.elements or {}) do if attr.value == clusters.PowerSource.attributes.BatPercentRemaining.ID then device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_PERCENTAGE, {persist=true}) - break - elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID and - device:get_field(fields.profiling_data.BATTERY_SUPPORT) ~= fields.battery_support.BATTERY_PERCENTAGE then -- don't overwrite if percentage support is already detected + break -- BATTERY_PERCENTAGE is highest priority. break early if found + elseif attr.value == clusters.PowerSource.attributes.BatChargeLevel.ID then device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.BATTERY_LEVEL, {persist=true}) end end - if not previous_battery_support or previous_battery_support ~= device:get_field(fields.profiling_data.BATTERY_SUPPORT) then + -- in the case that 1) no battery has been set, and 2) the returned ib does not include battery attributes, ignore battery + if latest_battery_support == nil and not device:get_field(fields.profiling_data.BATTERY_SUPPORT) then + device:set_field(fields.profiling_data.BATTERY_SUPPORT, fields.battery_support.NO_BATTERY, {persist=true}) + end + if latest_battery_support == nil or latest_battery_support ~= device:get_field(fields.profiling_data.BATTERY_SUPPORT) then device_cfg.match_profile(driver, device) end end diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua index ab2e075eb7..be1563f4f1 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/device_configuration.lua @@ -21,15 +21,15 @@ local SwitchDeviceConfiguration = {} local ButtonDeviceConfiguration = {} local FanDeviceConfiguration = {} -function ChildConfiguration.create_or_update_child_devices(driver, device, server_cluster_ep_ids, default_endpoint_id, assign_profile_fn) +function ChildConfiguration.create_or_update_child_devices(driver, device, server_cluster_ep_ids, default_endpoint_id, assign_profile_fn, label_prefix) + table.sort(server_cluster_ep_ids) if #server_cluster_ep_ids == 1 and server_cluster_ep_ids[1] == default_endpoint_id then -- no children will be created return end - table.sort(server_cluster_ep_ids) for device_num, ep_id in ipairs(server_cluster_ep_ids) do if ep_id ~= default_endpoint_id then -- don't create a child device that maps to the main endpoint - local label_and_name = string.format("%s %d", device.label, device_num) + local label_and_name = string.format("%s %d", label_prefix or device.label, device_num) local child_profile, _ = assign_profile_fn(device, ep_id, true) local existing_child_device = device:get_field(fields.IS_PARENT_CHILD_DEVICE) and switch_utils.find_child(device, ep_id) if not existing_child_device then @@ -205,10 +205,27 @@ end function DeviceConfiguration.match_profile(driver, device) if profiling_data_still_required(device) then return end - local default_endpoint_id = switch_utils.find_default_endpoint(device) + local default_endpoint_id, default_device_type = switch_utils.find_default_endpoint(device) local optional_component_capabilities local updated_profile + -- TODO: we only check this early for "Floodlight" labeling purposes, + -- but ideally that should be identified with a Floodlight Camera device type check + local camera_ep_ids = {} + if version.api >= 16 and version.rpc >= 10 then + camera_ep_ids = switch_utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.CAMERA) + end + + local server_onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) -- get_endpoints defaults to return EPs supporting SERVER or BOTH + if #server_onoff_ep_ids > 0 then + local label_prefix = #camera_ep_ids > 0 and "Floodlight" or nil -- if there's a camera EP, it's likely this OnOff EP is for a floodlight, so label the child device accordingly + ChildConfiguration.create_or_update_child_devices(driver, device, server_onoff_ep_ids, default_endpoint_id, SwitchDeviceConfiguration.assign_profile_for_onoff_ep, label_prefix) + end + + if default_device_type == fields.DEVICE_TYPE_ID.AGGREGATOR then + return + end + if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID) > 0 then updated_profile = "water-valve" if #embedded_cluster_utils.get_endpoints(device, clusters.ValveConfigurationAndControl.ID, @@ -217,11 +234,6 @@ function DeviceConfiguration.match_profile(driver, device) end end - local server_onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) -- get_endpoints defaults to return EPs supporting SERVER or BOTH - if #server_onoff_ep_ids > 0 then - ChildConfiguration.create_or_update_child_devices(driver, device, server_onoff_ep_ids, default_endpoint_id, SwitchDeviceConfiguration.assign_profile_for_onoff_ep) - end - if switch_utils.tbl_contains(server_onoff_ep_ids, default_endpoint_id) then updated_profile = SwitchDeviceConfiguration.assign_profile_for_onoff_ep(device, default_endpoint_id) local generic_profile = function(s) return string.find(updated_profile or "", s, 1, true) end @@ -246,13 +258,23 @@ function DeviceConfiguration.match_profile(driver, device) -- initialize the main device card with buttons if applicable local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) if switch_utils.tbl_contains(fields.STATIC_BUTTON_PROFILE_SUPPORTED, #momentary_switch_ep_ids) then - updated_profile = ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, #momentary_switch_ep_ids) - -- All button endpoints found will be added as additional components in the profile containing the default_endpoint_id. - ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, momentary_switch_ep_ids) + if #camera_ep_ids == 0 then + -- the Doorbell device type, which is a superset of the Generic Switch device type, is handled later on as a subset of the the camera profiling logic. + updated_profile = ButtonDeviceConfiguration.update_button_profile(device, default_endpoint_id, #momentary_switch_ep_ids) + -- All button endpoints found will be added as additional components in the profile containing the default_endpoint_id. + ButtonDeviceConfiguration.update_button_component_map(device, default_endpoint_id, momentary_switch_ep_ids) + end ButtonDeviceConfiguration.configure_buttons(device, momentary_switch_ep_ids) end + if #camera_ep_ids > 0 then + local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" + updated_profile, optional_component_capabilities = camera_cfg.assign_profile_for_camera_ep(device, camera_ep_ids[1]) + if not updated_profile then return false end + end + device:try_update_metadata({ profile = updated_profile, optional_component_capabilities = optional_component_capabilities }) + return updated_profile ~= nil end return { diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua index 1432b18c74..4e9a8271a5 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/fields.lua @@ -34,7 +34,7 @@ SwitchFields.DEVICE_TYPE_ID = { CAMERA = 0x0142, CHIME = 0x0146, DIMMABLE_PLUG_IN_UNIT = 0x010B, - DOORBELL = 0x0143, + DOORBELL = 0x0148, ELECTRICAL_SENSOR = 0x0510, FAN = 0x002B, GENERIC_SWITCH = 0x000F, @@ -157,6 +157,8 @@ SwitchFields.ELECTRICAL_TAGS = "__electrical_tags" SwitchFields.profiling_data = { POWER_TOPOLOGY = "__power_topology", BATTERY_SUPPORT = "__battery_support", + STATUS_LIGHT_ENABLED_PRESENT = "__status_light_enabled_present", + STATUS_LIGHT_BRIGHTNESS_PRESENT = "__status_light_brightness_present", } SwitchFields.battery_support = { diff --git a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua index f949a15a56..288024bbbe 100644 --- a/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua +++ b/drivers/SmartThings/matter-switch/src/switch_utils/utils.lua @@ -146,15 +146,11 @@ end --- find_default_endpoint is a helper function to handle situations where --- device does not have endpoint ids in sequential order from 1 function utils.find_default_endpoint(device) - -- Buttons should not be set on the main component for the Aqara Climate Sensor W100, + -- Buttons should not be set on the main component for the Aqara Climate Sensor W100 if utils.get_product_override_field(device, "is_climate_sensor_w100") then return device.MATTER_DEFAULT_ENDPOINT end - local onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) - local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) - local fan_endpoint_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) - local get_first_non_zero_endpoint = function(endpoints) table.sort(endpoints) for _,ep in ipairs(endpoints) do @@ -165,19 +161,37 @@ function utils.find_default_endpoint(device) return nil end + -- Return the first camera endpoint as the default endpoint if any is found + if version.rpc >= 10 and version.api >= 16 then + local camera_eps = device:get_endpoints(clusters.CameraAvStreamManagement.ID) + if #camera_eps > 0 then + return get_first_non_zero_endpoint(camera_eps), fields.DEVICE_TYPE_ID.CAMERA + end + end + + -- After camera, use aggregator as default if it is found + local aggregator_ep_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.AGGREGATOR) + if #aggregator_ep_ids > 0 then + return aggregator_ep_ids[1], fields.DEVICE_TYPE_ID.AGGREGATOR + end + + local onoff_ep_ids = device:get_endpoints(clusters.OnOff.ID) + local momentary_switch_ep_ids = device:get_endpoints(clusters.Switch.ID, {feature_bitmap=clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH}) + local fan_endpoint_ids = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.FAN) + -- Return the first fan endpoint as the default endpoint if any is found if #fan_endpoint_ids > 0 then - return get_first_non_zero_endpoint(fan_endpoint_ids) + return get_first_non_zero_endpoint(fan_endpoint_ids), fields.DEVICE_TYPE_ID.FAN end -- Return the first onoff endpoint as the default endpoint if no momentary switch endpoints are present if #momentary_switch_ep_ids == 0 and #onoff_ep_ids > 0 then - return get_first_non_zero_endpoint(onoff_ep_ids) + return get_first_non_zero_endpoint(onoff_ep_ids), fields.DEVICE_TYPE_ID.LIGHT.ON_OFF end -- Return the first momentary switch endpoint as the default endpoint if no onoff endpoints are present if #onoff_ep_ids == 0 and #momentary_switch_ep_ids > 0 then - return get_first_non_zero_endpoint(momentary_switch_ep_ids) + return get_first_non_zero_endpoint(momentary_switch_ep_ids), fields.DEVICE_TYPE_ID.LIGHT.GENERIC_SWITCH end -- If both onoff and momentary switch endpoints are present, check the device type on the first onoff @@ -186,21 +200,22 @@ function utils.find_default_endpoint(device) if #onoff_ep_ids > 0 and #momentary_switch_ep_ids > 0 then local default_endpoint_id = get_first_non_zero_endpoint(onoff_ep_ids) if utils.device_type_supports_button_switch_combination(device, default_endpoint_id) then - return default_endpoint_id + return default_endpoint_id, fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE else device.log.warn("The main switch endpoint does not contain a supported device type for a component configuration with buttons") - return get_first_non_zero_endpoint(momentary_switch_ep_ids) + return get_first_non_zero_endpoint(momentary_switch_ep_ids), fields.DEVICE_TYPE_ID.GENERIC_SWITCH end end device.log.warn(string.format("Did not find default endpoint, will use endpoint %d instead", device.MATTER_DEFAULT_ENDPOINT)) - return device.MATTER_DEFAULT_ENDPOINT + return device.MATTER_DEFAULT_ENDPOINT, utils.find_primary_device_type(utils.get_endpoint_info(device, device.MATTER_DEFAULT_ENDPOINT)) end function utils.component_to_endpoint(device, component) local map = device:get_field(fields.COMPONENT_TO_ENDPOINT_MAP) or {} if map[component] then - return map[component] + -- if it's not a number, it should be a table with at least an endpoint_id fields + return (type(map[component]) == "number") and map[component] or map[component].endpoint_id end return utils.find_default_endpoint(device) end @@ -370,10 +385,6 @@ function utils.deep_equals(a, b, opts, seen) return utils.deep_equals(mt_a, mt_b, opts, seen) end -function utils.detect_bridge(device) - return #utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.AGGREGATOR) > 0 -end - --- Generalizes the 'get_latest_state' function to be callable with extra endpoint information, described below, --- without directly specifying the expected component. See the 'get_latest_state' definition for more --- information about parameters and expected functionality otherwise. @@ -439,7 +450,7 @@ function utils.handle_electrical_sensor_info(device) local electrical_sensor_eps = utils.get_endpoints_by_device_type(device, fields.DEVICE_TYPE_ID.ELECTRICAL_SENSOR, { with_info = true }) if #electrical_sensor_eps == 0 then -- no Electrical Sensor EPs are supported. Set profiling data to false and return - device:set_field(fields.profiling_data.POWER_TOPOLOGY, false, {persist=true}) + utils.set_preprofiling_data(device, fields.profiling_data.POWER_TOPOLOGY, false) return end @@ -464,7 +475,7 @@ function utils.handle_electrical_sensor_info(device) device:set_field(fields.ELECTRICAL_SENSOR_EPS, electrical_sensor_eps) elseif clusters.PowerTopology.are_features_supported(clusters.PowerTopology.types.Feature.NODE_TOPOLOGY, endpoint_power_topology_feature_map) then -- EP has a NODE topology, so there is only ONE Electrical Sensor EP - device:set_field(fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.NODE_TOPOLOGY, {persist=true}) + utils.set_preprofiling_data(device, fields.profiling_data.POWER_TOPOLOGY, clusters.PowerTopology.types.Feature.NODE_TOPOLOGY) if utils.set_fields_for_electrical_sensor_endpoint(device, electrical_sensor_eps[1], device:get_endpoints(clusters.OnOff.ID)) == false then device.log.warn("Electrical Sensor EP with NODE topology found, but no OnOff EPs exist. Electrical Sensor capabilities will not be exposed.") end @@ -472,6 +483,10 @@ function utils.handle_electrical_sensor_info(device) end end +function utils.set_preprofiling_data(device, profiling_field, value) + device:set_field(profiling_field, value, {persist=true}) +end + function utils.lazy_load(sub_driver_name) if version.api >= 16 then return MatterDriver.lazy_load_sub_driver_v2(sub_driver_name) @@ -491,34 +506,43 @@ end --- helper for the switch subscribe override, which adds to a subscribed request for a checked device --- --- @param checked_device any a Matter device object, either a parent or child device, so not necessarily the same as device +--- @param parent_device any the parent Matter device object; could be the same as checked_device if checked_device is the parent device --- @param subscribe_request table a subscribe request that will be appended to as needed for the device --- @param capabilities_seen table a list of capabilities that have already been checked by previously handled devices --- @param attributes_seen table a list of attributes that have already been checked --- @param events_seen table a list of events that have already been checked --- @param subscribed_attributes table key-value pairs mapping capability ids to subscribed attributes --- @param subscribed_events table key-value pairs mapping capability ids to subscribed events -function utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, events_seen, subscribed_attributes, subscribed_events) - for _, component in pairs(checked_device.st_store.profile.components) do +local function populate_subscribe_request_for_device(checked_device, parent_device, subscribe_request, capabilities_seen, attributes_seen, events_seen, subscribed_attributes, subscribed_events) + for _, component in pairs(checked_device.st_store.profile.components) do for _, capability in pairs(component.capabilities) do if not capabilities_seen[capability.id] then for _, attr in ipairs(subscribed_attributes[capability.id] or {}) do local cluster_id = attr.cluster or attr._cluster.ID - local attr_id = attr.ID or attr.attribute - if not attributes_seen[cluster_id] or not attributes_seen[cluster_id][attr_id] then - local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) - subscribe_request:with_info_block(ib) - attributes_seen[cluster_id] = attributes_seen[cluster_id] or {} - attributes_seen[cluster_id][attr_id] = ib + if #parent_device:get_endpoints(cluster_id) > 0 then + local attr_id = attr.ID or attr.attribute + if not attributes_seen[cluster_id] or not attributes_seen[cluster_id][attr_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) + subscribe_request:with_info_block(ib) + attributes_seen[cluster_id] = attributes_seen[cluster_id] or {} + attributes_seen[cluster_id][attr_id] = ib + end + else + log.warn_with({ hub_logs = true }, string.format("Device does not support cluster 0x%04X, not adding subscribed attribute", cluster_id)) end end for _, event in ipairs(subscribed_events[capability.id] or {}) do local cluster_id = event.cluster or event._cluster.ID - local event_id = event.ID or event.event - if not events_seen[cluster_id] or not events_seen[cluster_id][event_id] then - local ib = im.InteractionInfoBlock(nil, cluster_id, nil, event_id) - subscribe_request:with_info_block(ib) - events_seen[cluster_id] = events_seen[cluster_id] or {} - events_seen[cluster_id][event_id] = ib + if #parent_device:get_endpoints(cluster_id) > 0 then + local event_id = event.ID or event.event + if not events_seen[cluster_id] or not events_seen[cluster_id][event_id] then + local ib = im.InteractionInfoBlock(nil, cluster_id, nil, event_id) + subscribe_request:with_info_block(ib) + events_seen[cluster_id] = events_seen[cluster_id] or {} + events_seen[cluster_id][event_id] = ib + end + else + log.warn_with({ hub_logs = true }, string.format("Device does not support cluster 0x%04X, not adding subscribed event", cluster_id)) end end capabilities_seen[capability.id] = true -- only loop through any capability once @@ -527,6 +551,53 @@ function utils.populate_subscribe_request_for_device(checked_device, subscribe_r end end +--- aggregate the subscribed_attributes and subscribed_events tables with capability-subscription tables found in sub-drivers +--- +--- @param device any a Matter device object +--- @param checked_device any a Matter device object, either a parent or child device, so not necessarily the same as device +--- @param subscribe_request any a subscribe request that will be appended to as needed for the device +--- @param subscribed_attributes any table key-value pairs mapping capability ids to subscribed attributes, which will be appended to as needed for the device +--- @param subscribed_events any table key-value pairs mapping capability ids to subscribed events, which will be appended to as needed for the device +local function aggregate_sub_driver_subscriptions(device, checked_device, subscribe_request, subscribed_attributes, subscribed_events) + for _, sub_driver in ipairs(device.driver.sub_drivers) do + if sub_driver.can_handle({}, device.driver, checked_device) then + local sub_driver_subscriptions = require(string.format("%s.%s_utils.subscriptions", sub_driver.NAME, string.gsub(sub_driver.NAME, "sub_drivers.", ""))) + for capability, cluster_attributes in pairs(sub_driver_subscriptions.subscribed_attributes or {}) do + if subscribed_attributes[capability] then + for _, attr in pairs(cluster_attributes or {}) do + if not utils.tbl_contains(subscribed_attributes[capability], attr) then + table.insert(subscribed_attributes[capability], attr) + end + end + else + subscribed_attributes[capability] = cluster_attributes + end + end + for capability, cluster_events in pairs(sub_driver_subscriptions.subscribed_events or {}) do + if subscribed_events[capability] then + for _, event in pairs(cluster_events or {}) do + if not utils.tbl_contains(subscribed_events[capability], event) then + table.insert(subscribed_events[capability], event) + end + end + else + subscribed_events[capability] = cluster_events + end + end + for condition_fn, cluster_attributes in pairs(sub_driver_subscriptions.conditional_subscriptions or {}) do + if condition_fn(checked_device) then + for _, cluster_attribute in pairs(cluster_attributes or {}) do + local cluster_id = cluster_attribute.cluster or cluster_attribute._cluster.ID + local attr_id = cluster_attribute.ID or cluster_attribute.attribute + local ib = im.InteractionInfoBlock(nil, cluster_id, attr_id) + subscribe_request:with_info_block(ib) + end + end + end + end + end +end + --- create and send a subscription request by checking all devices, accounting for both parent and child devices --- --- @param device any a Matter device object @@ -537,8 +608,10 @@ function utils.subscribe(device) for _, endpoint_info in ipairs(device.endpoints) do local checked_device = utils.find_child(device, endpoint_info.endpoint_id) or device if not devices_seen[checked_device.id] then - utils.populate_subscribe_request_for_device(checked_device, subscribe_request, capabilities_seen, attributes_seen, events_seen, - device.driver.subscribed_attributes, device.driver.subscribed_events + local subscribed_attributes, subscribed_events = device.driver.subscribed_attributes, device.driver.subscribed_events + aggregate_sub_driver_subscriptions(device, checked_device, subscribe_request, subscribed_attributes, subscribed_events) + populate_subscribe_request_for_device(checked_device, device, subscribe_request, capabilities_seen, attributes_seen, events_seen, + subscribed_attributes, subscribed_events ) devices_seen[checked_device.id] = true -- only loop through any device once end diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua index 305c7e682e..1e23ffff4a 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_climate_sensor_w100.lua @@ -130,7 +130,6 @@ local function test_init() end test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "added" }) - test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "init" }) test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) diff --git a/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua index 667e68ec16..9b29c85bfb 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_aqara_light_switch_h2.lua @@ -181,7 +181,6 @@ local function test_init() -- Test added -> doConfigure logic test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "added" }) - test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "init" }) test.socket.matter:__expect_send({aqara_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ aqara_mock_device.id, "doConfigure" }) diff --git a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua index 180604431b..1eaca84c36 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_set.lua @@ -183,7 +183,6 @@ local function test_init() end test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) - test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) end test.set_test_init_function(test_init) @@ -199,7 +198,6 @@ local function test_init_periodic() test.socket.matter:__expect_send({ mock_device_periodic.id, subscribe_request }) test.socket.device_lifecycle:__queue_receive({ mock_device_periodic.id, "init" }) test.socket.matter:__expect_send({ mock_device_periodic.id, subscribe_request }) - test.socket.matter:__expect_send({ mock_device_periodic.id, subscribe_request }) end test.register_message_test( diff --git a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua index f618389ba6..86a2c34d22 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_electrical_sensor_tree.lua @@ -130,7 +130,6 @@ local function test_init() end test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) - test.socket.matter:__expect_send({ mock_device.id, subscribe_request }) end test.set_test_init_function(test_init) diff --git a/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua b/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua index 5fa58ce8d3..e5c3b5dfc5 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_eve_energy.lua @@ -127,7 +127,6 @@ local function test_init_electrical_sensor() end test.socket.device_lifecycle:__queue_receive({ mock_device_electrical_sensor.id, "added" }) - test.socket.matter:__expect_send({mock_device_electrical_sensor.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_electrical_sensor.id, "init" }) test.socket.matter:__expect_send({mock_device_electrical_sensor.id, subscribe_request}) diff --git a/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua b/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua index ccf952a824..a7d34b7cd8 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_light_illuminance_motion.lua @@ -107,7 +107,6 @@ end local function test_init() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) -- the following subscribe is due to the init event sent by the test framework. test.socket.matter:__expect_send({mock_device.id, subscribe_request}) @@ -118,7 +117,6 @@ test.set_test_init_function(test_init) local function test_init_x_y_color_mode() test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.mock_device.add_test_device(mock_device) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua index 6cb679796d..0f108494df 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_bridge.lua @@ -38,7 +38,7 @@ local mock_bridge = test.mock_device.build_test_matter_device({ {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} }, device_types = { - {device_type_id = 0x0100, device_type_revision = 1} -- On/Off Light + {device_type_id = 0x0101, device_type_revision = 1} -- Dimmable Light } }, { @@ -53,7 +53,7 @@ local mock_bridge = test.mock_device.build_test_matter_device({ {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER"} }, device_types = { - {device_type_id = 0x0100, device_type_revision = 1} -- On/Off Light + {device_type_id = 0x0101, device_type_revision = 1} -- Dimmable Light } } } @@ -83,6 +83,22 @@ local function test_init_mock_bridge() test.mock_device.add_test_device(mock_bridge) test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "added" }) test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "init" }) + test.socket.matter:__expect_send({ mock_bridge.id, clusters.LevelControl.attributes.Options:write(mock_bridge, 1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) }) + test.socket.matter:__expect_send({ mock_bridge.id, clusters.LevelControl.attributes.Options:write(mock_bridge, 2, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) }) + mock_bridge:expect_device_create({ + type = "EDGE_CHILD", + label = "nil 1", + profile = "light-level", + parent_device_id = mock_bridge.id, + parent_assigned_child_key = string.format("%d", 1) + }) + mock_bridge:expect_device_create({ + type = "EDGE_CHILD", + label = "nil 2", + profile = "light-level", + parent_device_id = mock_bridge.id, + parent_assigned_child_key = string.format("%d", 2) + }) test.socket.device_lifecycle:__queue_receive({ mock_bridge.id, "doConfigure" }) mock_bridge:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua index 86ce8185bf..9d1a3e1698 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_button.lua @@ -104,7 +104,6 @@ local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) @@ -133,7 +132,6 @@ local function test_init_battery() test.disable_startup_messages() test.mock_device.add_test_device(mock_device_battery) test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "added" }) - test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "init" }) test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua index 7f1f72b108..30c66cea6c 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera.lua @@ -5,171 +5,78 @@ local capabilities = require "st.capabilities" local cluster_base = require "st.matter.cluster_base" local clusters = require "st.matter.clusters" local camera_fields = require "sub_drivers.camera.camera_utils.fields" +local switch_fields = require "switch_utils.fields" local t_utils = require "integration_test.utils" local test = require "integration_test" local uint32 = require "st.matter.data_types.Uint32" test.disable_startup_messages() -local CAMERA_EP, FLOODLIGHT_EP, CHIME_EP, DOORBELL_EP = 1, 2, 3, 4 +local CAMERA_EP = 1 -local mock_device = test.mock_device.build_test_matter_device({ - profile = t_utils.get_profile_definition("camera.yml"), - manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, - matter_version = {hardware = 1, software = 1}, - endpoints = { - { - endpoint_id = 0, - clusters = { - { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } - }, - device_types = { - { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode - } +local endpoints = { + { + endpoint_id = 0, + clusters = { + { cluster_id = clusters.Basic.ID, cluster_type = "SERVER" } }, - { - endpoint_id = CAMERA_EP, - clusters = { - { - cluster_id = clusters.CameraAvStreamManagement.ID, - feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO | - clusters.CameraAvStreamManagement.types.Feature.PRIVACY | - clusters.CameraAvStreamManagement.types.Feature.AUDIO | - clusters.CameraAvStreamManagement.types.Feature.LOCAL_STORAGE | - clusters.CameraAvStreamManagement.types.Feature.PRIVACY | - clusters.CameraAvStreamManagement.types.Feature.SPEAKER | - clusters.CameraAvStreamManagement.types.Feature.IMAGE_CONTROL | - clusters.CameraAvStreamManagement.types.Feature.SPEAKER | - clusters.CameraAvStreamManagement.types.Feature.HIGH_DYNAMIC_RANGE | - clusters.CameraAvStreamManagement.types.Feature.NIGHT_VISION | - clusters.CameraAvStreamManagement.types.Feature.WATERMARK | - clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY, - cluster_type = "SERVER" - }, - { - cluster_id = clusters.CameraAvSettingsUserLevelManagement.ID, - feature_map = clusters.CameraAvSettingsUserLevelManagement.types.Feature.DIGITALPTZ | - clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN | - clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT | - clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM | - clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PRESETS, - cluster_type = "SERVER" - }, - { - cluster_id = clusters.PushAvStreamTransport.ID, - cluster_type = "SERVER" - }, - { - cluster_id = clusters.ZoneManagement.ID, - feature_map = clusters.ZoneManagement.types.Feature.TWO_DIMENSIONAL_CARTESIAN_ZONE | - clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY, - cluster_type = "SERVER" - }, - { - cluster_id = clusters.WebRTCTransportProvider.ID, - cluster_type = "SERVER" - }, - { - cluster_id = clusters.OccupancySensing.ID, - cluster_type = "SERVER" - } + device_types = { + { device_type_id = 0x0016, device_type_revision = 1 } -- RootNode + } + }, + { + endpoint_id = CAMERA_EP, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO | + clusters.CameraAvStreamManagement.types.Feature.PRIVACY | + clusters.CameraAvStreamManagement.types.Feature.AUDIO | + clusters.CameraAvStreamManagement.types.Feature.LOCAL_STORAGE | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER | + clusters.CameraAvStreamManagement.types.Feature.IMAGE_CONTROL | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER | + clusters.CameraAvStreamManagement.types.Feature.HIGH_DYNAMIC_RANGE | + clusters.CameraAvStreamManagement.types.Feature.NIGHT_VISION | + clusters.CameraAvStreamManagement.types.Feature.WATERMARK | + clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY, + cluster_type = "SERVER" }, - device_types = { - {device_type_id = 0x0142, device_type_revision = 1} -- Camera - } - }, - { - endpoint_id = FLOODLIGHT_EP, - clusters = { - {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, - {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, - {cluster_id = clusters.ColorControl.ID, cluster_type = "BOTH", feature_map = 30} + { + cluster_id = clusters.CameraAvSettingsUserLevelManagement.ID, + feature_map = clusters.CameraAvSettingsUserLevelManagement.types.Feature.DIGITALPTZ | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PRESETS, + cluster_type = "SERVER" }, - device_types = { - {device_type_id = 0x010D, device_type_revision = 2} -- Extended Color Light - } - }, - { - endpoint_id = CHIME_EP, - clusters = { - { - cluster_id = clusters.Chime.ID, - cluster_type = "SERVER" - }, + { + cluster_id = clusters.PushAvStreamTransport.ID, + cluster_type = "SERVER" }, - device_types = { - {device_type_id = 0x0146, device_type_revision = 1} -- Chime - } - }, - { - endpoint_id = DOORBELL_EP, - clusters = { - { - cluster_id = clusters.Switch.ID, - feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | - clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | - clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, - cluster_type = "SERVER", - } + { + cluster_id = clusters.ZoneManagement.ID, + feature_map = clusters.ZoneManagement.types.Feature.TWO_DIMENSIONAL_CARTESIAN_ZONE | + clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.WebRTCTransportProvider.ID, + cluster_type = "SERVER" }, - device_types = { - {device_type_id = 0x0143, device_type_revision = 1} -- Doorbell + { + cluster_id = clusters.OccupancySensing.ID, + cluster_type = "SERVER" } + }, + device_types = { + {device_type_id = switch_fields.DEVICE_TYPE_ID.CAMERA, device_type_revision = 1} } } -}) - -local subscribe_request -local subscribed_attributes = { - clusters.CameraAvStreamManagement.attributes.AttributeList, - clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, - clusters.ColorControl.attributes.ColorMode, } -local function test_init() - test.mock_device.add_test_device(mock_device) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - local floodlight_child_device_data = { - profile = t_utils.get_profile_definition("light-color-level.yml"), - device_network_id = string.format("%s:%d", mock_device.id, FLOODLIGHT_EP), - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", FLOODLIGHT_EP) - } - test.mock_device.add_test_device(test.mock_device.build_test_child_device(floodlight_child_device_data)) - mock_device:expect_device_create({ - type = "EDGE_CHILD", - label = "Floodlight 1", - profile = "light-color-level", - parent_device_id = mock_device.id, - parent_assigned_child_key = string.format("%d", FLOODLIGHT_EP) - }) - subscribe_request = subscribed_attributes[1]:subscribe(mock_device) - subscribe_request:merge(cluster_base.subscribe(mock_device, nil, camera_fields.CameraAVSMFeatureMapAttr.cluster, camera_fields.CameraAVSMFeatureMapAttr.ID)) - subscribe_request:merge(cluster_base.subscribe(mock_device, nil, camera_fields.CameraAVSULMFeatureMapAttr.cluster, camera_fields.CameraAVSULMFeatureMapAttr.ID)) - subscribe_request:merge(cluster_base.subscribe(mock_device, nil, camera_fields.ZoneManagementFeatureMapAttr.cluster, camera_fields.ZoneManagementFeatureMapAttr.ID)) - for i, attr in ipairs(subscribed_attributes) do - if i > 1 then subscribe_request:merge(attr:subscribe(mock_device)) end - end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) - mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) -end - -test.set_test_init_function(test_init) - -local additional_subscribed_attributes = { +local additional_subscriptions = { clusters.CameraAvStreamManagement.attributes.HDRModeEnabled, clusters.CameraAvStreamManagement.attributes.ImageRotation, clusters.CameraAvStreamManagement.attributes.NightVision, @@ -188,8 +95,6 @@ local additional_subscribed_attributes = { clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel, clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel, clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel, - clusters.CameraAvStreamManagement.attributes.StatusLightBrightness, - clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled, clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled, @@ -208,8 +113,6 @@ local additional_subscribed_attributes = { clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin, clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams, - clusters.Chime.attributes.InstalledChimeSounds, - clusters.Chime.attributes.SelectedChime, clusters.ZoneManagement.attributes.MaxZones, clusters.ZoneManagement.attributes.Zones, clusters.ZoneManagement.attributes.Triggers, @@ -217,193 +120,74 @@ local additional_subscribed_attributes = { clusters.ZoneManagement.attributes.Sensitivity, clusters.ZoneManagement.events.ZoneTriggered, clusters.ZoneManagement.events.ZoneStopped, - clusters.OnOff.attributes.OnOff, - clusters.LevelControl.attributes.CurrentLevel, - clusters.LevelControl.attributes.MaxLevel, - clusters.LevelControl.attributes.MinLevel, - clusters.ColorControl.attributes.ColorTemperatureMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMaxMireds, - clusters.ColorControl.attributes.ColorTempPhysicalMinMireds, - clusters.ColorControl.attributes.CurrentHue, - clusters.ColorControl.attributes.CurrentSaturation, - clusters.ColorControl.attributes.CurrentX, - clusters.ColorControl.attributes.CurrentY, clusters.OccupancySensing.attributes.Occupancy, - clusters.Switch.server.events.InitialPress, - clusters.Switch.server.events.LongPress, - clusters.Switch.server.events.ShortRelease, - clusters.Switch.server.events.MultiPressComplete } +local function create_subscription(device) + local subscribe_request = clusters.CameraAvStreamManagement.attributes.AttributeList:subscribe(device) + subscribe_request:merge(cluster_base.subscribe(device, nil, camera_fields.CameraAVSMFeatureMapAttr.cluster, + camera_fields.CameraAVSMFeatureMapAttr.ID)) + subscribe_request:merge(cluster_base.subscribe(device, nil, camera_fields.CameraAVSULMFeatureMapAttr.cluster, + camera_fields.CameraAVSULMFeatureMapAttr.ID)) + subscribe_request:merge(cluster_base.subscribe(device, nil, camera_fields.ZoneManagementFeatureMapAttr.cluster, + camera_fields.ZoneManagementFeatureMapAttr.ID)) + for _, attr in ipairs(additional_subscriptions) do + subscribe_request:merge(attr:subscribe(device)) + end + return subscribe_request +end + local expected_metadata = { optional_component_capabilities = { - { - "main", - { - "videoCapture2", - "cameraViewportSettings", - "videoStreamSettings", - "localMediaStorage", - "audioRecording", - "cameraPrivacyMode", - "imageControl", - "hdr", - "nightVision", - "mechanicalPanTiltZoom", - "zoneManagement", - "webrtc", + {"main", { + "videoCapture2", "cameraViewportSettings", "videoStreamSettings", + "localMediaStorage", "audioRecording", "cameraPrivacyMode", + "imageControl", "hdr", "nightVision", + "mechanicalPanTiltZoom", "zoneManagement", "webrtc", "motionSensor", - "sounds", - } - }, - { - "statusLed", - { - "switch", - "mode" - } - }, - { - "speaker", - { - "audioMute", - "audioVolume" } }, - { - "microphone", - { - "audioMute", - "audioVolume" - } - }, - { - "doorbell", - { - "button" - } - } + {"speaker", {"audioMute", "audioVolume"}}, + {"microphone", {"audioMute", "audioVolume"}} }, profile = "camera" } -local function update_device_profile() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { - uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), - uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) - }) - }) +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("camera.yml", { enabled_optional_capabilities = expected_metadata.optional_component_capabilities }), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = endpoints +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) + mock_device:set_field(switch_fields.profiling_data.STATUS_LIGHT_BRIGHTNESS_PRESENT, false, {persist=true}) + mock_device:set_field(switch_fields.profiling_data.STATUS_LIGHT_ENABLED_PRESENT, false, {persist=true}) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, create_subscription(mock_device) }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) mock_device:expect_metadata_update(expected_metadata) - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) - test.wait_for_events() - local updated_device_profile = t_utils.get_profile_definition( - "camera.yml", {enabled_optional_capabilities = expected_metadata.optional_component_capabilities} - ) - test.wait_for_events() - test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ profile = updated_device_profile })) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.webrtc.supportedFeatures( - {audio="sendrecv", bundle=true, order="audio/video", supportTrickleICE=true, turnSource="player", video="recvonly"} - )) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.supportedAttributes( - {"pan", "panRange", "tilt", "tiltRange", "zoom", "zoomRange", "presets", "maxPresets"} - )) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.zoneManagement.supportedFeatures( - {"triggerAugmentation", "perZoneSensitivity"} - )) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.localMediaStorage.supportedAttributes( - {"localVideoRecording"} - )) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.audioRecording.audioRecording("enabled")) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.videoStreamSettings.supportedFeatures( - {"liveStreaming", "clipRecording", "perStreamViewports", "watermark", "onScreenDisplay"} - )) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes( - {"softRecordingPrivacyMode", "softLivestreamPrivacyMode"} - )) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedCommands( - {"setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode"} - )) - ) - for _, attr in ipairs(additional_subscribed_attributes) do - subscribe_request:merge(attr:subscribe(mock_device)) - end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) - test.socket.capability:__expect_send(mock_device:generate_test_message("doorbell", capabilities.button.button.pushed({state_change = false}))) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) end --- Matter Handler UTs +test.set_test_init_function(test_init) test.register_coroutine_test( - "Software version change should trigger camera reprofiling when camera endpoint is present", + "Software version change should initialize camera capabilities when profile is unchanged", function() + mock_device:set_field(switch_fields.profiling_data.STATUS_LIGHT_BRIGHTNESS_PRESENT, false) + mock_device:set_field(switch_fields.profiling_data.STATUS_LIGHT_ENABLED_PRESENT, false) + local camera_utils = require "sub_drivers.camera.camera_utils.utils" + camera_utils.optional_capabilities_list_changed = function () return false end -- integration profile ref logic makes this fn inaccurate + + local unchanged_profile = t_utils.get_profile_definition("camera.yml", { enabled_optional_capabilities = expected_metadata.optional_component_capabilities }) + unchanged_profile.id = "00000000-1111-2222-3333-000000000002" + unchanged_profile.preferences = nil test.socket.device_lifecycle:__queue_receive( - mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } }) + mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 }, profile = unchanged_profile }) ) - - mock_device:expect_metadata_update({ - optional_component_capabilities = { - { - "main", - { - "videoCapture2", - "cameraViewportSettings", - "videoStreamSettings", - "localMediaStorage", - "audioRecording", - "cameraPrivacyMode", - "imageControl", - "hdr", - "nightVision", - "mechanicalPanTiltZoom", - "zoneManagement", - "webrtc", - "motionSensor", - "sounds" - } - }, - { - "speaker", - { - "audioMute", - "audioVolume" - } - }, - { - "microphone", - { - "audioMute", - "audioVolume" - } - }, - { - "doorbell", - { - "button" - } - } - }, - profile = "camera" - }) - - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) end, { min_api_version = 17 @@ -411,65 +195,14 @@ test.register_coroutine_test( ) test.register_coroutine_test( - "Software version change should initialize camera capabilities when profile is unchanged", + "Software version change should trigger camera reprofiling when camera endpoint is present", function() - local camera_handler = require "sub_drivers.camera" - local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" - local button_cfg = require("switch_utils.device_configuration").ButtonCfg - - local match_profile_called = false - local init_called = false - local subscribe_called = false - local configure_buttons_called = false - - local fake_device = { - matter_version = { hardware = 1, software = 3 }, - profile = { id = "camera" }, - endpoints = { - { - endpoint_id = CAMERA_EP, - device_types = { - {device_type_id = 0x0142, device_type_revision = 1} -- Camera - } - }, - { - endpoint_id = DOORBELL_EP, - device_types = { - {device_type_id = 0x0143, device_type_revision = 1} -- Doorbell - } - } - }, - subscribe = function() subscribe_called = true end, - supports_capability = function() return false end, - get_endpoints = function() return { DOORBELL_EP } end, - } - - local original_match_profile = camera_cfg.match_profile - local original_init = camera_cfg.initialize_camera_capabilities - local original_configure_buttons = button_cfg.configure_buttons - - camera_cfg.match_profile = function() - match_profile_called = true - return false - end - camera_cfg.initialize_camera_capabilities = function() init_called = true end - button_cfg.configure_buttons = function() configure_buttons_called = true end - - camera_handler.lifecycle_handlers.infoChanged(nil, fake_device, nil, { - old_st_store = { - matter_version = { hardware = 1, software = 1 }, - profile = fake_device.profile, - } - }) - - camera_cfg.match_profile = original_match_profile - camera_cfg.initialize_camera_capabilities = original_init - button_cfg.configure_buttons = original_configure_buttons - - assert(match_profile_called, "match_profile should be called on software version change") - assert(not init_called, "initialize_camera_capabilities should not be called when capability state is unchanged") - assert(not subscribe_called, "subscribe should not be called when capability state is unchanged") - assert(not configure_buttons_called, "configure_buttons should not be called when capability state is unchanged") + mock_device:set_field(switch_fields.profiling_data.STATUS_LIGHT_BRIGHTNESS_PRESENT, false) + mock_device:set_field(switch_fields.profiling_data.STATUS_LIGHT_ENABLED_PRESENT, false) + test.socket.device_lifecycle:__queue_receive( + mock_device:generate_info_changed({ matter_version = { hardware = 1, software = 2 } }) + ) + mock_device:expect_metadata_update(expected_metadata) end, { min_api_version = 17 @@ -480,21 +213,17 @@ test.register_coroutine_test( "Camera FeatureMap change should reinitialize capabilities when profile is unchanged", function() local camera_cfg = require "sub_drivers.camera.camera_utils.device_configuration" - local reconcile_called = false local original_reconcile = camera_cfg.reconcile_profile_and_capabilities - camera_cfg.reconcile_profile_and_capabilities = function(_) reconcile_called = true return false end - test.socket.matter:__queue_receive({ mock_device.id, cluster_base.build_test_report_data(mock_device, CAMERA_EP, camera_fields.CameraAVSMFeatureMapAttr.cluster, camera_fields.CameraAVSMFeatureMapAttr.ID, uint32(0)) }) test.wait_for_events() - camera_cfg.reconcile_profile_and_capabilities = original_reconcile assert(reconcile_called, "reconcile_profile_and_capabilities should be called") end, @@ -503,61 +232,9 @@ test.register_coroutine_test( } ) -test.register_coroutine_test( - "Reports mapping to EnabledState capability data type should generate appropriate events", - function() - update_device_profile() - test.wait_for_events() - local cluster_to_capability_map = { - {cluster = clusters.CameraAvStreamManagement.server.attributes.HDRModeEnabled, capability = capabilities.hdr.hdr}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.ImageFlipHorizontal, capability = capabilities.imageControl.imageFlipHorizontal}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.ImageFlipVertical, capability = capabilities.imageControl.imageFlipVertical}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.SoftRecordingPrivacyModeEnabled, capability = capabilities.cameraPrivacyMode.softRecordingPrivacyMode}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.SoftLivestreamPrivacyModeEnabled, capability = capabilities.cameraPrivacyMode.softLivestreamPrivacyMode}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.HardPrivacyModeOn, capability = capabilities.cameraPrivacyMode.hardPrivacyMode}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.LocalSnapshotRecordingEnabled, capability = capabilities.localMediaStorage.localSnapshotRecording}, - {cluster = clusters.CameraAvStreamManagement.server.attributes.LocalVideoRecordingEnabled, capability = capabilities.localMediaStorage.localVideoRecording} - } - for _, v in ipairs(cluster_to_capability_map) do - test.socket.matter:__queue_receive({ - mock_device.id, - v.cluster:build_test_report_data(mock_device, CAMERA_EP, true) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", v.capability("enabled")) - ) - if v.capability == capabilities.imageControl.imageFlipHorizontal then - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageFlipHorizontal"})) - ) - elseif v.capability == capabilities.imageControl.imageFlipVertical then - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageFlipHorizontal", "imageFlipVertical"})) - ) - elseif v.capability == capabilities.cameraPrivacyMode.hardPrivacyMode then - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes({"softRecordingPrivacyMode", "softLivestreamPrivacyMode", "hardPrivacyMode"})) - ) - end - test.socket.matter:__queue_receive({ - mock_device.id, - v.cluster:build_test_report_data(mock_device, CAMERA_EP, false) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", v.capability("disabled")) - ) - end - end, - { - min_api_version = 17 - } -) - test.register_coroutine_test( "Night Vision reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() local cluster_to_capability_map = { {cluster = clusters.CameraAvStreamManagement.server.attributes.NightVision, capability = capabilities.nightVision.nightVision}, {cluster = clusters.CameraAvStreamManagement.server.attributes.NightVisionIllum, capability = capabilities.nightVision.illumination} @@ -600,8 +277,6 @@ test.register_coroutine_test( "Image Rotation reports should generate appropriate events", function() local utils = require "st.utils" - update_device_profile() - test.wait_for_events() local first_value = true for angle = 0, 400, 50 do test.socket.matter:__queue_receive({ @@ -628,8 +303,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Two Way Talk Support reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, clusters.CameraAvStreamManagement.server.attributes.TwoWayTalkSupport:build_test_report_data( @@ -672,8 +345,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Muted reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() local cluster_to_component_map = { {cluster = clusters.CameraAvStreamManagement.server.attributes.SpeakerMuted, component = "speaker"}, {cluster = clusters.CameraAvStreamManagement.server.attributes.MicrophoneMuted, component = "microphone"} @@ -703,8 +374,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Volume Level reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() local max_vol = 200 local min_vol = 0 test.socket.matter:__queue_receive({ @@ -750,84 +419,6 @@ test.register_coroutine_test( } ) -test.register_coroutine_test( - "Status Light Enabled reports should generate appropriate events", - function() - update_device_profile() - test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:build_test_report_data(mock_device, CAMERA_EP, true) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.switch.switch.on()) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:build_test_report_data(mock_device, CAMERA_EP, false) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.switch.switch.off()) - ) - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Status Light Brightness reports should generate appropriate events", - function() - update_device_profile() - test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( - mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.LOW) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.supportedModes( - {"low", "medium", "high", "auto"}, {visibility = {displayed = false}}) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.supportedArguments( - {"low", "medium", "high", "auto"}, {visibility = {displayed = false}}) - ) - ) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.mode("low")) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( - mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.MEDIUM) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.mode("medium")) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( - mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.HIGH) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.mode("high")) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( - mock_device, CAMERA_EP, clusters.Global.types.ThreeLevelAutoEnum.AUTO) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("statusLed", capabilities.mode.mode("auto")) - ) - end, - { - min_api_version = 17 - } -) - local function receive_rate_distortion_trade_off_points() test.socket.matter:__queue_receive({ mock_device.id, @@ -937,8 +528,6 @@ end test.register_coroutine_test( "Rate Distortion Trade Off Points, MaxEncodedPixelRate, MinViewport, VideoSensorParams reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() receive_rate_distortion_trade_off_points() receive_max_encoded_pixel_rate() receive_min_viewport() @@ -955,8 +544,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Rate Distortion Trade Off Points, MinViewport, VideoSensorParams, MaxEncodedPixelRate reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() receive_rate_distortion_trade_off_points() receive_min_viewport() emit_min_viewport() @@ -973,8 +560,6 @@ test.register_coroutine_test( test.register_coroutine_test( "MaxEncodedPixelRate, MinViewport, VideoSensorParams, Rate Distortion Trade Off Points reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() receive_max_encoded_pixel_rate() receive_min_viewport() emit_min_viewport() @@ -991,8 +576,6 @@ test.register_coroutine_test( test.register_coroutine_test( "PTZ Position reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax:build_test_report_data(mock_device, CAMERA_EP, 150) @@ -1046,8 +629,6 @@ test.register_coroutine_test( test.register_coroutine_test( "PTZ Presets reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets:build_test_report_data( @@ -1070,8 +651,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Max Presets reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets:build_test_report_data(mock_device, CAMERA_EP, 10) @@ -1088,8 +667,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Max Zones reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, clusters.ZoneManagement.attributes.MaxZones:build_test_report_data(mock_device, CAMERA_EP, 10) @@ -1106,8 +683,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Zones reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, clusters.ZoneManagement.attributes.Zones:build_test_report_data( @@ -1154,8 +729,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Triggers reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, clusters.ZoneManagement.attributes.Triggers:build_test_report_data( @@ -1192,8 +765,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Sensitivity reports should generate appropriate events", function() - update_device_profile() - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, clusters.ZoneManagement.attributes.SensitivityMax:build_test_report_data(mock_device, CAMERA_EP, 7) @@ -1215,42 +786,11 @@ test.register_coroutine_test( } ) -test.register_coroutine_test( - "Chime reports should generate appropriate events", - function() - update_device_profile() - test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.Chime.attributes.InstalledChimeSounds:build_test_report_data(mock_device, CAMERA_EP, { - clusters.Chime.types.ChimeSoundStruct({chime_id = 1, name = "Sound 1"}), - clusters.Chime.types.ChimeSoundStruct({chime_id = 2, name = "Sound 2"}) - }) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("main", capabilities.sounds.supportedSounds({ - {id = 1, label = "Sound 1"}, - {id = 2, label = "Sound 2"}, - }, {visibility = {displayed = false}})) - ) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.Chime.attributes.SelectedChime:build_test_report_data(mock_device, CAMERA_EP, 2) - }) - test.socket.capability:__expect_send(mock_device:generate_test_message("main", capabilities.sounds.selectedSound(2))) - end, - { - min_api_version = 17 - } -) - -- Event Handler UTs test.register_coroutine_test( "Zone events should generate appropriate events", function() - update_device_profile() - test.wait_for_events() test.socket.matter:__queue_receive({ mock_device.id, clusters.ZoneManagement.events.ZoneTriggered:build_test_event_report(mock_device, CAMERA_EP, { @@ -1287,39 +827,11 @@ test.register_coroutine_test( } ) -test.register_coroutine_test( - "Button events should generate appropriate events", - function() - update_device_profile() - test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.Switch.server.events.InitialPress:build_test_event_report(mock_device, DOORBELL_EP, {new_position = 1}) - }) - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.Switch.server.events.MultiPressComplete:build_test_event_report(mock_device, DOORBELL_EP, { - new_position = 1, - total_number_of_presses_counted = 2, - previous_position = 0 - }) - }) - test.socket.capability:__expect_send( - mock_device:generate_test_message("doorbell", capabilities.button.button.double({state_change = true})) - ) - end, - { - min_api_version = 17 - } -) - -- Capability Handler UTs test.register_coroutine_test( "Set night vision commands should send the appropriate commands", function() - update_device_profile() - test.wait_for_events() local command_to_attribute_map = { ["setNightVision"] = clusters.CameraAvStreamManagement.attributes.NightVision, ["setIllumination"] = clusters.CameraAvStreamManagement.attributes.NightVisionIllum @@ -1356,8 +868,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Set enabled commands should send the appropriate commands", function() - update_device_profile() - test.wait_for_events() local command_to_attribute_map = { ["setHdr"] = { capability = "hdr", attr = clusters.CameraAvStreamManagement.attributes.HDRModeEnabled}, ["setImageFlipHorizontal"] = { capability = "imageControl", attr = clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal}, @@ -1392,8 +902,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Set image rotation command should send the appropriate commands", function() - update_device_profile() - test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, { capability = "imageControl", component = "main", command = "setImageRotation", args = { 10 } }, @@ -1417,8 +925,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Set mute commands should send the appropriate commands", function() - update_device_profile() - test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, { capability = "audioMute", component = "speaker", command = "setMute", args = { "muted" } }, @@ -1484,8 +990,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Set Volume command should send the appropriate commands", function() - update_device_profile() - test.wait_for_events() local max_vol = 200 local min_vol = 5 test.socket.matter:__queue_receive({ @@ -1589,62 +1093,9 @@ test.register_coroutine_test( } ) -test.register_coroutine_test( - "Set Mode command should send the appropriate commands", - function() - update_device_profile() - test.wait_for_events() - local mode_to_enum_map = { - ["low"] = clusters.Global.types.ThreeLevelAutoEnum.LOW, - ["medium"] = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM, - ["high"] = clusters.Global.types.ThreeLevelAutoEnum.HIGH, - ["auto"] = clusters.Global.types.ThreeLevelAutoEnum.AUTO - } - for i, v in pairs(mode_to_enum_map) do - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "mode", component = "speaker", command = "setMode", args = { i } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:write(mock_device, CAMERA_EP, v) - }) - end - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Set Status LED commands should send the appropriate commands", - function() - update_device_profile() - test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "switch", component = "statusLed", command = "on", args = { } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(mock_device, CAMERA_EP, true) - }) - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "switch", component = "statusLed", command = "off", args = { } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(mock_device, CAMERA_EP, false) - }) - end, - { - min_api_version = 17 - } -) - test.register_coroutine_test( "Set Relative PTZ commands should send the appropriate commands", function() - update_device_profile() - test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, { capability = "mechanicalPanTiltZoom", component = "main", command = "panRelative", args = { 10 } }, @@ -1675,8 +1126,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Set PTZ commands should send the appropriate commands", function() - update_device_profile() - test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, { capability = "mechanicalPanTiltZoom", component = "main", command = "setPanTiltZoom", args = { 10, 20, 30 } }, @@ -1756,8 +1205,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Preset commands should send the appropriate commands", function() - update_device_profile() - test.wait_for_events() test.socket.capability:__queue_receive({ mock_device.id, { capability = "mechanicalPanTiltZoom", component = "main", command = "savePreset", args = { 1, "Preset 1" } }, @@ -1785,36 +1232,10 @@ test.register_coroutine_test( } ) -test.register_coroutine_test( - "Sound commands should send the appropriate commands", - function() - update_device_profile() - test.wait_for_events() - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "sounds", component = "main", command = "setSelectedSound", args = { 1 } }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.Chime.attributes.SelectedChime:write(mock_device, CAMERA_EP, 1) - }) - test.socket.capability:__queue_receive({ - mock_device.id, - { capability = "sounds", component = "main", command = "playSound", args = {} }, - }) - test.socket.matter:__expect_send({ - mock_device.id, clusters.Chime.server.commands.PlayChimeSound(mock_device, CAMERA_EP) - }) - end, - { - min_api_version = 17 - } -) test.register_coroutine_test( "Zone Management zone commands should send the appropriate commands", function() - update_device_profile() - test.wait_for_events() local use_map = { ["motion"] = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, ["focus"] = clusters.ZoneManagement.types.ZoneUseEnum.FOCUS, @@ -1887,8 +1308,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Zone Management zone commands should send the appropriate commands - missing optional color argument", function() - update_device_profile() - test.wait_for_events() local use_map = { ["motion"] = clusters.ZoneManagement.types.ZoneUseEnum.MOTION, ["focus"] = clusters.ZoneManagement.types.ZoneUseEnum.FOCUS, @@ -1960,9 +1379,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Zone Management trigger commands should send the appropriate commands", function() - update_device_profile() - test.wait_for_events() - -- Create the trigger test.socket.capability:__queue_receive({ mock_device.id, @@ -2036,9 +1452,6 @@ test.register_coroutine_test( test.register_coroutine_test( "Removing a zone with an existing trigger should send RemoveTrigger followed by RemoveZone", function() - update_device_profile() - test.wait_for_events() - -- Create a zone test.socket.capability:__queue_receive({ mock_device.id, @@ -2128,8 +1541,6 @@ test.register_coroutine_test( test.register_coroutine_test( "setStream with label and viewport changes should emit capability event", function() - update_device_profile() - test.wait_for_events() -- Set up an existing stream test.socket.matter:__queue_receive({ mock_device.id, @@ -2234,8 +1645,6 @@ test.register_coroutine_test( test.register_coroutine_test( "setStream with only watermark/OSD changes should use VideoStreamModify", function() - update_device_profile() - test.wait_for_events() -- Set up an existing stream test.socket.matter:__queue_receive({ mock_device.id, @@ -2310,8 +1719,7 @@ test.register_coroutine_test( test.register_coroutine_test( "setStream with only label change should emit capability event", function() - update_device_profile() - test.wait_for_events() + -- Set up existing stream test.socket.matter:__queue_receive({ mock_device.id, @@ -2401,8 +1809,7 @@ test.register_coroutine_test( test.register_coroutine_test( "setStream with only viewport change should send DPTZSetViewport command", function() - update_device_profile() - test.wait_for_events() + -- Set up existing stream test.socket.matter:__queue_receive({ mock_device.id, @@ -2503,8 +1910,7 @@ test.register_coroutine_test( test.register_coroutine_test( "setStream with resolution change should trigger reallocation", function() - update_device_profile() - test.wait_for_events() + -- Set up existing stream test.socket.matter:__queue_receive({ mock_device.id, @@ -2656,8 +2062,7 @@ test.register_coroutine_test( test.register_coroutine_test( "Stream label should persist across attribute reports", function() - update_device_profile() - test.wait_for_events() + -- Set up existing stream test.socket.matter:__queue_receive({ mock_device.id, @@ -2772,8 +2177,7 @@ test.register_coroutine_test( test.register_coroutine_test( "DPTZStreams attribute should update viewports in capability", function() - update_device_profile() - test.wait_for_events() + -- Set up multiple existing streams test.socket.matter:__queue_receive({ mock_device.id, @@ -2892,73 +2296,4 @@ test.register_coroutine_test( end ) -test.register_coroutine_test( - "Camera profile should not update for an unchanged Status Light AttributeList report", - function() - update_device_profile() - test.wait_for_events() - - local camera_cfg = require("sub_drivers.camera.camera_utils.device_configuration") - local original_reconcile = camera_cfg.reconcile_profile_and_capabilities - camera_cfg.reconcile_profile_and_capabilities = function(...) return false end - - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { - uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), - uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) - }) - }) - test.wait_for_events() - - camera_cfg.reconcile_profile_and_capabilities = original_reconcile - end, - { - min_api_version = 17 - } -) - -test.register_coroutine_test( - "Camera profile should update for a changed Status Light AttributeList report", - function() - update_device_profile() - test.wait_for_events() - test.socket.matter:__queue_receive({ - mock_device.id, - clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP, { - uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID) - }) - }) - local updated_expected_metadata = { - optional_component_capabilities = { - { "main", - { "videoCapture2", "cameraViewportSettings", "videoStreamSettings", "localMediaStorage", "audioRecording", - "cameraPrivacyMode", "imageControl", "hdr", "nightVision", "mechanicalPanTiltZoom", "zoneManagement", - "webrtc", "motionSensor", "sounds", } - }, - { "statusLed", - { "switch" } -- only switch capability remains - }, - { "speaker", - { "audioMute", "audioVolume" } - }, - { "microphone", - { "audioMute", "audioVolume" } - }, - { "doorbell", - { "button" } - } - }, - profile = "camera" - } - mock_device:expect_metadata_update(updated_expected_metadata) - test.socket.matter:__expect_send({mock_device.id, clusters.Switch.attributes.MultiPressMax:read(mock_device, DOORBELL_EP)}) - test.socket.capability:__expect_send(mock_device:generate_test_message("doorbell", capabilities.button.button.pushed({state_change = false}))) - end, - { - min_api_version = 17 - } -) - --- run the tests test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_camera_configuration.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_camera_configuration.lua new file mode 100644 index 0000000000..3c7676e8e4 --- /dev/null +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_camera_configuration.lua @@ -0,0 +1,738 @@ +-- Copyright © 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +local cluster_base = require "st.matter.cluster_base" +local clusters = require "st.matter.clusters" +local camera_fields = require "sub_drivers.camera.camera_utils.fields" +local switch_fields = require "switch_utils.fields" +local t_utils = require "integration_test.utils" +local test = require "integration_test" +local uint32 = require "st.matter.data_types.Uint32" + +test.disable_startup_messages() + +local CAMERA_EP_ID = 1 +local CAMERA_EP = { + endpoint_id = CAMERA_EP_ID, + clusters = { + { + cluster_id = clusters.CameraAvStreamManagement.ID, + feature_map = clusters.CameraAvStreamManagement.types.Feature.VIDEO | + clusters.CameraAvStreamManagement.types.Feature.PRIVACY | + clusters.CameraAvStreamManagement.types.Feature.AUDIO | + clusters.CameraAvStreamManagement.types.Feature.LOCAL_STORAGE | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER | + clusters.CameraAvStreamManagement.types.Feature.IMAGE_CONTROL | + clusters.CameraAvStreamManagement.types.Feature.SPEAKER | + clusters.CameraAvStreamManagement.types.Feature.HIGH_DYNAMIC_RANGE | + clusters.CameraAvStreamManagement.types.Feature.NIGHT_VISION | + clusters.CameraAvStreamManagement.types.Feature.WATERMARK | + clusters.CameraAvStreamManagement.types.Feature.ON_SCREEN_DISPLAY, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.CameraAvSettingsUserLevelManagement.ID, + feature_map = clusters.CameraAvSettingsUserLevelManagement.types.Feature.DIGITALPTZ | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PAN | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_TILT | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_ZOOM | + clusters.CameraAvSettingsUserLevelManagement.types.Feature.MECHANICAL_PRESETS, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.PushAvStreamTransport.ID, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.ZoneManagement.ID, + feature_map = clusters.ZoneManagement.types.Feature.TWO_DIMENSIONAL_CARTESIAN_ZONE | + clusters.ZoneManagement.types.Feature.PER_ZONE_SENSITIVITY, + cluster_type = "SERVER" + }, + { + cluster_id = clusters.WebRTCTransportProvider.ID, + cluster_type = "SERVER" + } + }, + device_types = { + {device_type_id = switch_fields.DEVICE_TYPE_ID.CAMERA, device_type_revision = 1} + } + } + +local CHIME_EP_ID = 2 +local CHIME_EP = { + endpoint_id = CHIME_EP_ID, + clusters = { + { + cluster_id = clusters.Chime.ID, + cluster_type = "SERVER" + }, + }, + device_types = { + {device_type_id = switch_fields.DEVICE_TYPE_ID.CHIME, device_type_revision = 1} -- Chime + } +} + +local DOORBELL_EP_ID = 3 +local DOORBELL_EP = { + endpoint_id = DOORBELL_EP_ID, + clusters = { + { + cluster_id = clusters.Switch.ID, + feature_map = clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_MULTI_PRESS | + clusters.Switch.types.SwitchFeature.MOMENTARY_SWITCH_LONG_PRESS, + cluster_type = "SERVER", + } + }, + device_types = { + {device_type_id = switch_fields.DEVICE_TYPE_ID.DOORBELL, device_type_revision = 1} + } +} + +local FLOODLIGHT_EP_ID = 4 +local FLOODLIGHT_EP = { + endpoint_id = FLOODLIGHT_EP_ID, + clusters = { + {cluster_id = clusters.OnOff.ID, cluster_type = "SERVER"}, + {cluster_id = clusters.LevelControl.ID, cluster_type = "SERVER", feature_map = 2}, + }, + + device_types = { + {device_type_id = switch_fields.DEVICE_TYPE_ID.LIGHT.DIMMABLE, device_type_revision = 2} + } +} + +local chime_subscriptions = { + clusters.Chime.attributes.InstalledChimeSounds, + clusters.Chime.attributes.SelectedChime +} + +local doorbell_subscriptions = { + clusters.Switch.server.events.InitialPress, + clusters.Switch.server.events.LongPress, + clusters.Switch.server.events.ShortRelease, + clusters.Switch.server.events.MultiPressComplete +} + +local floodlight_subscriptions = { + clusters.OnOff.attributes.OnOff, + clusters.LevelControl.attributes.CurrentLevel, + clusters.LevelControl.attributes.MaxLevel, + clusters.LevelControl.attributes.MinLevel, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, -- also required due to switch cluster catching it +} + +local status_led_subscriptions = { + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness +} + +local camera_subscriptions = { + clusters.CameraAvStreamManagement.attributes.HDRModeEnabled, + clusters.CameraAvStreamManagement.attributes.ImageRotation, + clusters.CameraAvStreamManagement.attributes.NightVision, + clusters.CameraAvStreamManagement.attributes.NightVisionIllum, + clusters.CameraAvStreamManagement.attributes.ImageFlipHorizontal, + clusters.CameraAvStreamManagement.attributes.ImageFlipVertical, + clusters.CameraAvStreamManagement.attributes.SoftRecordingPrivacyModeEnabled, + clusters.CameraAvStreamManagement.attributes.SoftLivestreamPrivacyModeEnabled, + clusters.CameraAvStreamManagement.attributes.HardPrivacyModeOn, + clusters.CameraAvStreamManagement.attributes.TwoWayTalkSupport, + clusters.CameraAvStreamManagement.attributes.SpeakerMuted, + clusters.CameraAvStreamManagement.attributes.MicrophoneMuted, + clusters.CameraAvStreamManagement.attributes.SpeakerVolumeLevel, + clusters.CameraAvStreamManagement.attributes.SpeakerMaxLevel, + clusters.CameraAvStreamManagement.attributes.SpeakerMinLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneVolumeLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMaxLevel, + clusters.CameraAvStreamManagement.attributes.MicrophoneMinLevel, + clusters.CameraAvStreamManagement.attributes.RateDistortionTradeOffPoints, + clusters.CameraAvStreamManagement.attributes.LocalSnapshotRecordingEnabled, + clusters.CameraAvStreamManagement.attributes.LocalVideoRecordingEnabled, + clusters.CameraAvStreamManagement.attributes.MaxEncodedPixelRate, + clusters.CameraAvStreamManagement.attributes.VideoSensorParams, + clusters.CameraAvStreamManagement.attributes.AllocatedVideoStreams, + clusters.CameraAvStreamManagement.attributes.Viewport, + clusters.CameraAvStreamManagement.attributes.MinViewportResolution, + clusters.CameraAvStreamManagement.attributes.AttributeList, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPosition, + clusters.CameraAvSettingsUserLevelManagement.attributes.MPTZPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.MaxPresets, + clusters.CameraAvSettingsUserLevelManagement.attributes.ZoomMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.PanMin, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMax, + clusters.CameraAvSettingsUserLevelManagement.attributes.TiltMin, + clusters.CameraAvSettingsUserLevelManagement.attributes.DPTZStreams, + clusters.ZoneManagement.attributes.MaxZones, + clusters.ZoneManagement.attributes.Zones, + clusters.ZoneManagement.attributes.Triggers, + clusters.ZoneManagement.attributes.SensitivityMax, + clusters.ZoneManagement.attributes.Sensitivity, + clusters.ZoneManagement.events.ZoneTriggered, + clusters.ZoneManagement.events.ZoneStopped, +} + +local function create_subscription(device, with_camera, with_status_led, with_floodlight, with_doorbell, with_chime) + local subscribe_request = clusters.CameraAvStreamManagement.attributes.AttributeList:subscribe(device) + subscribe_request:merge(cluster_base.subscribe(device, nil, camera_fields.CameraAVSMFeatureMapAttr.cluster, camera_fields.CameraAVSMFeatureMapAttr.ID)) + subscribe_request:merge(cluster_base.subscribe(device, nil, camera_fields.CameraAVSULMFeatureMapAttr.cluster, camera_fields.CameraAVSULMFeatureMapAttr.ID)) + subscribe_request:merge(cluster_base.subscribe(device, nil, camera_fields.ZoneManagementFeatureMapAttr.cluster, camera_fields.ZoneManagementFeatureMapAttr.ID)) + local merge_subscriptions = function(cluster_list) + for _, attr in ipairs(cluster_list) do + subscribe_request:merge(attr:subscribe(device)) + end + end + if with_camera then merge_subscriptions(camera_subscriptions) end + if with_status_led then merge_subscriptions(status_led_subscriptions) end + if with_floodlight then merge_subscriptions(floodlight_subscriptions) end + if with_doorbell then merge_subscriptions(doorbell_subscriptions) end + if with_chime then merge_subscriptions(chime_subscriptions) end + return subscribe_request +end + +local expected_metadata = { + optional_component_capabilities = { + {"main", { + "videoCapture2", "cameraViewportSettings", "videoStreamSettings", + "localMediaStorage", "audioRecording", "cameraPrivacyMode", + "imageControl", "hdr", "nightVision", + "mechanicalPanTiltZoom", "zoneManagement", "webrtc", + } + }, + {"speaker", {"audioMute", "audioVolume"}}, + {"microphone", {"audioMute", "audioVolume"}} + }, + profile = "camera" +} + +local expected_metadata_with_status_led = { + optional_component_capabilities = { + {"main", { + "videoCapture2", "cameraViewportSettings", "videoStreamSettings", + "localMediaStorage", "audioRecording", "cameraPrivacyMode", + "imageControl", "hdr", "nightVision", + "mechanicalPanTiltZoom", "zoneManagement", "webrtc", + } + }, + {"statusLed", {"switch", "mode"}}, + {"speaker", {"audioMute", "audioVolume"}}, + {"microphone", {"audioMute", "audioVolume"}} + }, + profile = "camera" +} + +local mock_device = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("camera.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { CAMERA_EP } +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) + test.socket.matter:__expect_send({ mock_device.id, create_subscription(mock_device, false) }) + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + +test.set_test_init_function(test_init) + +local function mock_initial_camera_update(device, updated_optional_component_capabilities, updated_subscription, with_doorbell_events) + local updated_device_profile = t_utils.get_profile_definition( + "camera.yml", {enabled_optional_capabilities = updated_optional_component_capabilities} + ) + test.socket.device_lifecycle:__queue_receive(device:generate_info_changed({ profile = updated_device_profile })) + if with_doorbell_events then + test.socket.capability:__expect_send(device:generate_test_message("doorbell", capabilities.button.button.pushed({state_change = false}))) + end + test.socket.capability:__expect_send( + device:generate_test_message("main", capabilities.webrtc.supportedFeatures( + {audio="sendrecv", bundle=true, order="audio/video", supportTrickleICE=true, turnSource="player", video="recvonly"} + )) + ) + test.socket.capability:__expect_send( + device:generate_test_message("main", capabilities.mechanicalPanTiltZoom.supportedAttributes( + {"pan", "panRange", "tilt", "tiltRange", "zoom", "zoomRange", "presets", "maxPresets"} + )) + ) + test.socket.capability:__expect_send( + device:generate_test_message("main", capabilities.zoneManagement.supportedFeatures( + {"triggerAugmentation", "perZoneSensitivity"} + )) + ) + test.socket.capability:__expect_send( + device:generate_test_message("main", capabilities.localMediaStorage.supportedAttributes( + {"localVideoRecording"} + )) + ) + test.socket.capability:__expect_send( + device:generate_test_message("main", capabilities.audioRecording.audioRecording("enabled")) + ) + test.socket.capability:__expect_send( + device:generate_test_message("main", capabilities.videoStreamSettings.supportedFeatures( + {"liveStreaming", "clipRecording", "perStreamViewports", "watermark", "onScreenDisplay"} + )) + ) + test.socket.capability:__expect_send( + device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes( + {"softRecordingPrivacyMode", "softLivestreamPrivacyMode"} + )) + ) + test.socket.capability:__expect_send( + device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedCommands( + {"setSoftRecordingPrivacyMode", "setSoftLivestreamPrivacyMode"} + )) + ) + test.socket.matter:__expect_send({device.id, updated_subscription}) + if with_doorbell_events then + test.socket.matter:__expect_send({device.id, clusters.Switch.attributes.MultiPressMax:read(device, DOORBELL_EP_ID)}) + end + test.wait_for_events() +end + +test.register_coroutine_test( + "Initial profile update should trigger appropriate capability updates and subscriptions", + function () + mock_initial_camera_update(mock_device, expected_metadata.optional_component_capabilities, create_subscription(mock_device, true)) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Reports mapping to EnabledState capability data type should generate appropriate events", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP_ID, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + mock_device:expect_metadata_update(expected_metadata_with_status_led) + test.wait_for_events() + mock_initial_camera_update(mock_device, expected_metadata_with_status_led.optional_component_capabilities, create_subscription(mock_device, true, true)) + local cluster_to_capability_map = { + {cluster = clusters.CameraAvStreamManagement.server.attributes.HDRModeEnabled, capability = capabilities.hdr.hdr}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.ImageFlipHorizontal, capability = capabilities.imageControl.imageFlipHorizontal}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.ImageFlipVertical, capability = capabilities.imageControl.imageFlipVertical}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.SoftRecordingPrivacyModeEnabled, capability = capabilities.cameraPrivacyMode.softRecordingPrivacyMode}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.SoftLivestreamPrivacyModeEnabled, capability = capabilities.cameraPrivacyMode.softLivestreamPrivacyMode}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.HardPrivacyModeOn, capability = capabilities.cameraPrivacyMode.hardPrivacyMode}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.LocalSnapshotRecordingEnabled, capability = capabilities.localMediaStorage.localSnapshotRecording}, + {cluster = clusters.CameraAvStreamManagement.server.attributes.LocalVideoRecordingEnabled, capability = capabilities.localMediaStorage.localVideoRecording} + } + for _, v in ipairs(cluster_to_capability_map) do + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP_ID, true) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("enabled")) + ) + if v.capability == capabilities.imageControl.imageFlipHorizontal then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageFlipHorizontal"})) + ) + elseif v.capability == capabilities.imageControl.imageFlipVertical then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.imageControl.supportedAttributes({"imageFlipHorizontal", "imageFlipVertical"})) + ) + elseif v.capability == capabilities.cameraPrivacyMode.hardPrivacyMode then + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.cameraPrivacyMode.supportedAttributes({"softRecordingPrivacyMode", "softLivestreamPrivacyMode", "hardPrivacyMode"})) + ) + end + test.socket.matter:__queue_receive({ + mock_device.id, + v.cluster:build_test_report_data(mock_device, CAMERA_EP_ID, false) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", v.capability("disabled")) + ) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Status Light Enabled reports should generate appropriate events", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP_ID, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + mock_device:expect_metadata_update(expected_metadata_with_status_led) + test.wait_for_events() + mock_initial_camera_update(mock_device, expected_metadata_with_status_led.optional_component_capabilities, create_subscription(mock_device, true, true)) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:build_test_report_data(mock_device, CAMERA_EP_ID, true) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.switch.switch.on()) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:build_test_report_data(mock_device, CAMERA_EP_ID, false) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.switch.switch.off()) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Status Light Brightness reports should generate appropriate events", + function() + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP_ID, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + mock_device:expect_metadata_update(expected_metadata_with_status_led) + test.wait_for_events() + mock_initial_camera_update(mock_device, expected_metadata_with_status_led.optional_component_capabilities, create_subscription(mock_device, true, true)) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP_ID, clusters.Global.types.ThreeLevelAutoEnum.LOW) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.supportedModes( + {"low", "medium", "high", "auto"}, {visibility = {displayed = false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.supportedArguments( + {"low", "medium", "high", "auto"}, {visibility = {displayed = false}}) + ) + ) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("low")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP_ID, clusters.Global.types.ThreeLevelAutoEnum.MEDIUM) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("medium")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP_ID, clusters.Global.types.ThreeLevelAutoEnum.HIGH) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("high")) + ) + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:build_test_report_data( + mock_device, CAMERA_EP_ID, clusters.Global.types.ThreeLevelAutoEnum.AUTO) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("statusLed", capabilities.mode.mode("auto")) + ) + end, + { + min_api_version = 17 + } +) + + +test.register_coroutine_test( + "Set Mode command should send the appropriate commands", + function() + mock_initial_camera_update(mock_device, expected_metadata_with_status_led.optional_component_capabilities, create_subscription(mock_device, true, true)) + local mode_to_enum_map = { + ["low"] = clusters.Global.types.ThreeLevelAutoEnum.LOW, + ["medium"] = clusters.Global.types.ThreeLevelAutoEnum.MEDIUM, + ["high"] = clusters.Global.types.ThreeLevelAutoEnum.HIGH, + ["auto"] = clusters.Global.types.ThreeLevelAutoEnum.AUTO + } + for i, v in pairs(mode_to_enum_map) do + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "mode", component = "speaker", command = "setMode", args = { i } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightBrightness:write(mock_device, CAMERA_EP_ID, v) + }) + end + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Set Status LED commands should send the appropriate commands", + function() + mock_initial_camera_update(mock_device, expected_metadata_with_status_led.optional_component_capabilities, create_subscription(mock_device, true, true)) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "statusLed", command = "on", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(mock_device, CAMERA_EP_ID, true) + }) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "switch", component = "statusLed", command = "off", args = { } }, + }) + test.socket.matter:__expect_send({ + mock_device.id, clusters.CameraAvStreamManagement.attributes.StatusLightEnabled:write(mock_device, CAMERA_EP_ID, false) + }) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Camera profile should not update for an unchanged Status Light AttributeList report", + function() + local camera_cfg = require("sub_drivers.camera.camera_utils.device_configuration") + local original_reconcile = camera_cfg.reconcile_profile_and_capabilities + camera_cfg.reconcile_profile_and_capabilities = function(...) return false end + + test.socket.matter:__queue_receive({ + mock_device.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device, CAMERA_EP_ID, { + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightEnabled.ID), + uint32(clusters.CameraAvStreamManagement.attributes.StatusLightBrightness.ID) + }) + }) + test.wait_for_events() + camera_cfg.reconcile_profile_and_capabilities = original_reconcile + end, + { + min_api_version = 17 + } +) + + +local expected_metadata_with_doorbell_chime = { + optional_component_capabilities = { + {"main", { + "videoCapture2", "cameraViewportSettings", "videoStreamSettings", + "localMediaStorage", "audioRecording", "cameraPrivacyMode", + "imageControl", "hdr", "nightVision", + "mechanicalPanTiltZoom", "zoneManagement", "webrtc", + "sounds" -- chime specific capability + } + }, + {"speaker", {"audioMute", "audioVolume"}}, + {"microphone", {"audioMute", "audioVolume"}}, + {"doorbell", {"button"}} -- doorbell specific component and capability + }, + profile = "camera" +} + +local mock_device_doorbell_chime_camera = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("camera.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { CAMERA_EP, DOORBELL_EP, CHIME_EP } +}) + +local function test_init_doorbell_chime_camera() + test.mock_device.add_test_device(mock_device_doorbell_chime_camera) + test.socket.device_lifecycle:__queue_receive({ mock_device_doorbell_chime_camera.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_doorbell_chime_camera.id, "init" }) + test.socket.matter:__expect_send({ mock_device_doorbell_chime_camera.id, create_subscription(mock_device_doorbell_chime_camera) }) + test.socket.device_lifecycle:__queue_receive({ mock_device_doorbell_chime_camera.id, "doConfigure" }) + mock_device_doorbell_chime_camera:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + + +test.register_coroutine_test( + "Button events should generate appropriate events", + function() + test.socket.matter:__queue_receive({ + mock_device_doorbell_chime_camera.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device_doorbell_chime_camera, CAMERA_EP_ID, {uint32(0)}) + }) + mock_device_doorbell_chime_camera:expect_metadata_update(expected_metadata_with_doorbell_chime) + test.socket.matter:__expect_send({mock_device_doorbell_chime_camera.id, clusters.Switch.attributes.MultiPressMax:read(mock_device_doorbell_chime_camera, DOORBELL_EP_ID)}) + test.wait_for_events() + mock_initial_camera_update(mock_device_doorbell_chime_camera, expected_metadata_with_doorbell_chime.optional_component_capabilities, + create_subscription(mock_device_doorbell_chime_camera, true, false, false, true, true), true + ) + + test.socket.matter:__queue_receive({ + mock_device_doorbell_chime_camera.id, + clusters.Switch.server.events.InitialPress:build_test_event_report(mock_device_doorbell_chime_camera, DOORBELL_EP_ID, {new_position = 1}) + }) + test.socket.matter:__queue_receive({ + mock_device_doorbell_chime_camera.id, + clusters.Switch.server.events.MultiPressComplete:build_test_event_report(mock_device_doorbell_chime_camera, DOORBELL_EP_ID, { + new_position = 1, + total_number_of_presses_counted = 2, + previous_position = 0 + }) + }) + test.socket.capability:__expect_send( + mock_device_doorbell_chime_camera:generate_test_message("doorbell", capabilities.button.button.double({state_change = true})) + ) + end, + { + test_init = test_init_doorbell_chime_camera + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Sound commands should send the appropriate commands", + function() + test.socket.matter:__queue_receive({ + mock_device_doorbell_chime_camera.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device_doorbell_chime_camera, CAMERA_EP_ID, {uint32(0)}) + }) + mock_device_doorbell_chime_camera:expect_metadata_update(expected_metadata_with_doorbell_chime) + test.socket.matter:__expect_send({mock_device_doorbell_chime_camera.id, clusters.Switch.attributes.MultiPressMax:read(mock_device_doorbell_chime_camera, DOORBELL_EP_ID)}) + test.wait_for_events() + mock_initial_camera_update(mock_device_doorbell_chime_camera, expected_metadata_with_doorbell_chime.optional_component_capabilities, + create_subscription(mock_device_doorbell_chime_camera, true, false, false, true, true), true + ) + + test.socket.capability:__queue_receive({ + mock_device_doorbell_chime_camera.id, + { capability = "sounds", component = "main", command = "setSelectedSound", args = { 1 } }, + }) + test.socket.matter:__expect_send({ + mock_device_doorbell_chime_camera.id, clusters.Chime.attributes.SelectedChime:write(mock_device_doorbell_chime_camera, CAMERA_EP_ID, 1) + }) + test.socket.capability:__queue_receive({ + mock_device_doorbell_chime_camera.id, + { capability = "sounds", component = "main", command = "playSound", args = {} }, + }) + test.socket.matter:__expect_send({ + mock_device_doorbell_chime_camera.id, clusters.Chime.server.commands.PlayChimeSound(mock_device_doorbell_chime_camera, CAMERA_EP_ID) + }) + end, + { + test_init = test_init_doorbell_chime_camera + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "Chime reports should generate appropriate events", + function() + test.socket.matter:__queue_receive({ + mock_device_doorbell_chime_camera.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device_doorbell_chime_camera, CAMERA_EP_ID, {uint32(0)}) + }) + mock_device_doorbell_chime_camera:expect_metadata_update(expected_metadata_with_doorbell_chime) + test.socket.matter:__expect_send({mock_device_doorbell_chime_camera.id, clusters.Switch.attributes.MultiPressMax:read(mock_device_doorbell_chime_camera, DOORBELL_EP_ID)}) + test.wait_for_events() + mock_initial_camera_update(mock_device_doorbell_chime_camera, expected_metadata_with_doorbell_chime.optional_component_capabilities, + create_subscription(mock_device_doorbell_chime_camera, true, false, false, true, true), true + ) + + test.socket.matter:__queue_receive({ + mock_device_doorbell_chime_camera.id, + clusters.Chime.attributes.InstalledChimeSounds:build_test_report_data(mock_device_doorbell_chime_camera, CAMERA_EP_ID, { + clusters.Chime.types.ChimeSoundStruct({chime_id = 1, name = "Sound 1"}), + clusters.Chime.types.ChimeSoundStruct({chime_id = 2, name = "Sound 2"}) + }) + }) + test.socket.capability:__expect_send( + mock_device_doorbell_chime_camera:generate_test_message("main", capabilities.sounds.supportedSounds({ + {id = 1, label = "Sound 1"}, + {id = 2, label = "Sound 2"}, + }, {visibility = {displayed = false}})) + ) + test.socket.matter:__queue_receive({ + mock_device_doorbell_chime_camera.id, + clusters.Chime.attributes.SelectedChime:build_test_report_data(mock_device_doorbell_chime_camera, CAMERA_EP_ID, 2) + }) + test.socket.capability:__expect_send(mock_device_doorbell_chime_camera:generate_test_message("main", capabilities.sounds.selectedSound(2))) + end, + { + test_init = test_init_doorbell_chime_camera + }, + { + min_api_version = 17 + } +) + + +local mock_device_floodlight_camera = test.mock_device.build_test_matter_device({ + profile = t_utils.get_profile_definition("camera.yml"), + manufacturer_info = {vendor_id = 0x0000, product_id = 0x0000}, + matter_version = {hardware = 1, software = 1}, + endpoints = { CAMERA_EP, FLOODLIGHT_EP } +}) + +local floodlight_child_device_data = { + profile = t_utils.get_profile_definition("light-level.yml"), + device_network_id = string.format("%s:%d", mock_device_floodlight_camera.id, FLOODLIGHT_EP_ID), + parent_device_id = mock_device_floodlight_camera.id, + parent_assigned_child_key = string.format("%d", FLOODLIGHT_EP_ID) +} +local mock_floodlight_child = test.mock_device.build_test_child_device(floodlight_child_device_data) + +local function test_init_floodlight_camera() + test.mock_device.add_test_device(mock_device_floodlight_camera) + test.socket.device_lifecycle:__queue_receive({ mock_device_floodlight_camera.id, "added" }) + test.socket.device_lifecycle:__queue_receive({ mock_device_floodlight_camera.id, "init" }) + test.socket.matter:__expect_send({ mock_device_floodlight_camera.id, create_subscription(mock_device_floodlight_camera) }) + test.socket.matter:__expect_send({ mock_device_floodlight_camera.id, clusters.LevelControl.attributes.Options:write(mock_device_floodlight_camera, FLOODLIGHT_EP_ID, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF) }) + test.socket.device_lifecycle:__queue_receive({ mock_device_floodlight_camera.id, "doConfigure" }) + mock_device_floodlight_camera:expect_metadata_update({ provisioning_state = "PROVISIONED" }) +end + + +test.register_coroutine_test( + "Child Floodlight device should be created when OnOff cluster is present on a separate endpoint", + function() + test.mock_device.add_test_device(mock_floodlight_child) + test.socket.matter:__queue_receive({ + mock_device_floodlight_camera.id, + clusters.CameraAvStreamManagement.attributes.AttributeList:build_test_report_data(mock_device_floodlight_camera, CAMERA_EP_ID, {uint32(0)}) + }) + mock_device_floodlight_camera:expect_device_create({ + type = "EDGE_CHILD", + label = "Floodlight 1", + profile = "light-level", + parent_device_id = mock_device_floodlight_camera.id, + parent_assigned_child_key = string.format("%d", FLOODLIGHT_EP_ID) + }) + mock_device_floodlight_camera:expect_metadata_update(expected_metadata) + test.wait_for_events() + mock_initial_camera_update(mock_device_floodlight_camera, expected_metadata.optional_component_capabilities, create_subscription(mock_device_floodlight_camera, true, false, true)) + end, + { + test_init = test_init_floodlight_camera + }, + { + min_api_version = 17 + } +) + + +test.run_registered_tests() diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua index 6a8ab5657a..3f7402471d 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_light_fan.lua @@ -131,7 +131,6 @@ local function test_init() test.socket.matter:__expect_send({mock_device.id, subscribe_request}) -- since all fan capabilities are optional, nothing is initially subscribed to test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "doConfigure" }) test.socket.matter:__expect_send({mock_device.id, clusters.LevelControl.attributes.Options:write(mock_device, mock_device_ep1, clusters.LevelControl.types.OptionsBitmap.EXECUTE_IF_OFF)}) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua index 2225ed129b..4333721ebb 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button.lua @@ -221,8 +221,6 @@ local function test_init() test.disable_startup_messages() test.mock_device.add_test_device(mock_device) -- make sure the cache is populated - -- added sets a bunch of fields on the device, and calls init - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) -- init results in subscription interaction @@ -254,7 +252,6 @@ local function test_init_battery() test.disable_startup_messages() test.mock_device.add_test_device(mock_device_battery) - test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_battery.id, "added" }) test.socket.matter:__expect_send({mock_device_battery.id, subscribe_request}) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua index 68fc7e8339..f271bd906c 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_motion.lua @@ -155,7 +155,6 @@ local function test_init() for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST) do if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) -- init results in subscription interaction diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua index 0c4b314791..b551dd7b21 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_multi_button_switch_mcd.lua @@ -216,7 +216,6 @@ local function test_init() for i, clus in ipairs(CLUSTER_SUBSCRIBE_LIST_NO_CHILD) do if i > 1 then subscribe_request:merge(clus:subscribe(mock_device)) end end - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) @@ -412,7 +411,6 @@ test.register_coroutine_test( subscribe_request:merge(cluster:subscribe(unsup_mock_device)) end test.socket.device_lifecycle:__queue_receive({ unsup_mock_device.id, "added" }) - test.socket.matter:__expect_send({unsup_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ unsup_mock_device.id, "init" }) test.socket.matter:__expect_send({unsup_mock_device.id, subscribe_request}) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_sensor_offset_preferences.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_sensor_offset_preferences.lua index 3b3cefd8e3..929937db14 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_sensor_offset_preferences.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_sensor_offset_preferences.lua @@ -42,17 +42,10 @@ local function test_init() test.mock_device.add_test_device(mock_device) local cluster_subscribe_list = { - clusters.Switch.events.InitialPress, - clusters.Switch.events.LongPress, - clusters.Switch.events.ShortRelease, - clusters.Switch.events.MultiPressComplete, - clusters.TemperatureMeasurement.attributes.MeasuredValue, clusters.TemperatureMeasurement.attributes.MinMeasuredValue, clusters.TemperatureMeasurement.attributes.MaxMeasuredValue, - clusters.RelativeHumidityMeasurement.attributes.MeasuredValue, - clusters.PowerSource.attributes.BatPercentRemaining } local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device) @@ -63,7 +56,6 @@ local function test_init() end test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua index a1b60be357..3c9f0b9713 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_switch.lua @@ -154,7 +154,6 @@ local function test_init() end end test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) -- note that since disable_startup_messages is not explicitly called here, -- the following subscribe is due to the init event sent by the test framework. @@ -172,7 +171,6 @@ local function test_init_x_y_color_mode() end end test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.mock_device.add_test_device(mock_device) @@ -187,7 +185,6 @@ local function test_init_no_hue_sat() end end test.socket.device_lifecycle:__queue_receive({ mock_device_no_hue_sat.id, "added" }) - test.socket.matter:__expect_send({mock_device_no_hue_sat.id, subscribe_request}) test.socket.matter:__expect_send({mock_device_no_hue_sat.id, subscribe_request}) test.mock_device.add_test_device(mock_device_no_hue_sat) @@ -215,7 +212,6 @@ local function test_init_color_temp() end test.socket.device_lifecycle:__queue_receive({ mock_device_color_temp.id, "added" }) - test.socket.matter:__expect_send({mock_device_color_temp.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_color_temp.id, "init" }) test.socket.matter:__expect_send({mock_device_color_temp.id, subscribe_request}) @@ -241,7 +237,6 @@ local function test_init_extended_color() subscribe_request:merge(cluster:subscribe(mock_device_extended_color)) end end - test.socket.matter:__expect_send({mock_device_extended_color.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_extended_color.id, "added" }) test.socket.device_lifecycle:__queue_receive({ mock_device_extended_color.id, "init" }) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_switch_device_types.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_switch_device_types.lua index 37b5ec8ca5..d5e6bfadcb 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_switch_device_types.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_switch_device_types.lua @@ -513,7 +513,6 @@ local function test_init_switch_vendor_override() test.mock_device.add_test_device(mock_device_switch_vendor_override) local subscribe_request = clusters.OnOff.attributes.OnOff:subscribe(mock_device_switch_vendor_override) test.socket.device_lifecycle:__queue_receive({ mock_device_switch_vendor_override.id, "added" }) - test.socket.matter:__expect_send({mock_device_switch_vendor_override.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_switch_vendor_override.id, "init" }) test.socket.matter:__expect_send({mock_device_switch_vendor_override.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_switch_vendor_override.id, "doConfigure" }) @@ -533,7 +532,6 @@ local function test_init_mounted_on_off_control() end end test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_on_off_control.id, "added" }) - test.socket.matter:__expect_send({mock_device_mounted_on_off_control.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_on_off_control.id, "init" }) test.socket.matter:__expect_send({mock_device_mounted_on_off_control.id, subscribe_request}) @@ -562,7 +560,6 @@ local function test_init_mounted_dimmable_load_control() end end test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_dimmable_load_control.id, "added" }) - test.socket.matter:__expect_send({mock_device_mounted_dimmable_load_control.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_mounted_dimmable_load_control.id, "init" }) test.socket.matter:__expect_send({mock_device_mounted_dimmable_load_control.id, subscribe_request}) @@ -592,7 +589,6 @@ local function test_init_parent_child_different_types() } local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_device_parent_child_different_types) test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_different_types.id, "added" }) - test.socket.matter:__expect_send({mock_device_parent_child_different_types.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_different_types.id, "init" }) test.socket.matter:__expect_send({mock_device_parent_child_different_types.id, subscribe_request}) @@ -656,7 +652,6 @@ local function test_init_light_level_motion() end test.socket.device_lifecycle:__queue_receive({ mock_device_light_level_motion.id, "added" }) - test.socket.matter:__expect_send({mock_device_light_level_motion.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_light_level_motion.id, "init" }) test.socket.matter:__expect_send({mock_device_light_level_motion.id, subscribe_request}) diff --git a/drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua b/drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua index 338acd58c7..b7420675ab 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_matter_water_valve.lua @@ -58,7 +58,6 @@ local function test_init() end end test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) -- the following subscribe is due to the init event sent by the test framework. test.socket.matter:__expect_send({mock_device.id, subscribe_request}) diff --git a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua index 7976b27a4a..5dc9945ddc 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_mcd.lua @@ -146,7 +146,6 @@ local function test_init_mock_3switch() test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_3switch) test.socket.device_lifecycle:__queue_receive({ mock_3switch.id, "added" }) - test.socket.matter:__expect_send({mock_3switch.id, subscribe_request}) -- the following subscribe is due to the init event sent by the test framework. test.socket.matter:__expect_send({mock_3switch.id, subscribe_request}) @@ -162,7 +161,6 @@ local function test_init_mock_2switch() test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_2switch) test.socket.device_lifecycle:__queue_receive({ mock_2switch.id, "added" }) - test.socket.matter:__expect_send({mock_2switch.id, subscribe_request}) test.socket.matter:__expect_send({mock_2switch.id, subscribe_request}) test.mock_device.add_test_device(mock_2switch) @@ -177,7 +175,6 @@ local function test_init_mock_3switch_non_sequential() test.socket.matter:__set_channel_ordering("relaxed") local subscribe_request = cluster_subscribe_list[1]:subscribe(mock_3switch_non_sequential) test.socket.device_lifecycle:__queue_receive({ mock_3switch_non_sequential.id, "added" }) - test.socket.matter:__expect_send({mock_3switch_non_sequential.id, subscribe_request}) test.socket.matter:__expect_send({mock_3switch_non_sequential.id, subscribe_request}) test.mock_device.add_test_device(mock_3switch_non_sequential) diff --git a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_lights.lua b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_lights.lua index 8102c61865..e6313964a2 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_lights.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_lights.lua @@ -177,7 +177,6 @@ local function test_init() end test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) @@ -255,7 +254,6 @@ local function test_init_parent_child_endpoints_non_sequential() end test.socket.device_lifecycle:__queue_receive({ unsup_mock_device.id, "added" }) - test.socket.matter:__expect_send({unsup_mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ unsup_mock_device.id, "init" }) test.socket.matter:__expect_send({unsup_mock_device.id, subscribe_request}) diff --git a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_plugs.lua b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_plugs.lua index 9beed1805e..1c7a86d410 100644 --- a/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_plugs.lua +++ b/drivers/SmartThings/matter-switch/src/test/test_multi_switch_parent_child_plugs.lua @@ -173,7 +173,6 @@ local function test_init() end test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) - test.socket.matter:__expect_send({mock_device.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device.id, "init" }) test.socket.matter:__expect_send({mock_device.id, subscribe_request}) @@ -249,7 +248,6 @@ local function test_init_parent_child_endpoints_non_sequential() end test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_endpoints_non_sequential.id, "added" }) - test.socket.matter:__expect_send({mock_device_parent_child_endpoints_non_sequential.id, subscribe_request}) test.socket.device_lifecycle:__queue_receive({ mock_device_parent_child_endpoints_non_sequential.id, "init" }) test.socket.matter:__expect_send({mock_device_parent_child_endpoints_non_sequential.id, subscribe_request}) diff --git a/drivers/SmartThings/zigbee-contact/fingerprints.yml b/drivers/SmartThings/zigbee-contact/fingerprints.yml index c30c3296a2..dd4bb9175c 100644 --- a/drivers/SmartThings/zigbee-contact/fingerprints.yml +++ b/drivers/SmartThings/zigbee-contact/fingerprints.yml @@ -204,6 +204,16 @@ zigbeeManufacturer: manufacturer: Third Reality, Inc model: 3RVS01031Z deviceProfileName: thirdreality-multi-sensor + - id: "SONOFF/SNZB-04P" + deviceLabel: SONOFF Contact Sensor + manufacturer: eWeLink + model: SNZB-04P + deviceProfileName: contact-battery-profile + - id: "SONOFF/SNZB-04PR2" + deviceLabel: SONOFF Contact Sensor + manufacturer: SONOFF + model: SNZB-04PR2 + deviceProfileName: contact-battery-profile - id: "Aug. Winkhaus SE/FM.V.ZB" deviceLabel: Funkkontakt FM.V.ZB manufacturer: Aug. Winkhaus SE diff --git a/drivers/SmartThings/zigbee-contact/profiles/contact-battery-tamper.yml b/drivers/SmartThings/zigbee-contact/profiles/contact-battery-tamper.yml new file mode 100644 index 0000000000..524783af37 --- /dev/null +++ b/drivers/SmartThings/zigbee-contact/profiles/contact-battery-tamper.yml @@ -0,0 +1,16 @@ +name: contact-battery-tamper +components: +- id: main + capabilities: + - id: contactSensor + version: 1 + - id: battery + version: 1 + - id: tamperAlert + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: ContactSensor diff --git a/drivers/SmartThings/zigbee-contact/src/configurations.lua b/drivers/SmartThings/zigbee-contact/src/configurations.lua index cd5ede1b6e..668e8c5ac6 100644 --- a/drivers/SmartThings/zigbee-contact/src/configurations.lua +++ b/drivers/SmartThings/zigbee-contact/src/configurations.lua @@ -26,7 +26,6 @@ local devices = { EWELINK_HEIMAN = { FINGERPRINTS = { { mfr = "eWeLink", model = "DS01" }, - { mfr = "eWeLink", model = "SNZB-04P" }, { mfr = "HEIMAN", model = "DoorSensor-N" } }, CONFIGURATION = { diff --git a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml index e5ad9e571a..d897ca7ecf 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml +++ b/drivers/SmartThings/zigbee-humidity-sensor/fingerprints.yml @@ -83,6 +83,11 @@ zigbeeManufacturer: manufacturer: eWeLink model: SNZB-02P deviceProfileName: humidity-temp-battery + - id: "SONOFF/SNZB-02DR2" + deviceLabel: "SONOFF SNZB-02DR2" + manufacturer: SONOFF + model: SNZB-02DR2 + deviceProfileName: humidity-temp-battery - id: "Third Reality/3RTHS24BZ" deviceLabel: ThirdReality Temperature and Humidity Sensor manufacturer: Third Reality, Inc diff --git a/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua b/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua index 8bc35c8765..043359768f 100644 --- a/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua +++ b/drivers/SmartThings/zigbee-humidity-sensor/src/configurations.lua @@ -56,7 +56,8 @@ local devices = { EWELINK_HUMIDITY_TEMP_SENSOR = { FINGERPRINTS = { { mfr = "eWeLink", model = "TH01" }, - { mfr = "eWeLink", model = "SNZB-02P" } + { mfr = "eWeLink", model = "SNZB-02P" }, + { mfr = "SONOFF", model = "SNZB-02DR2" } }, CONFIGURATION = { { diff --git a/drivers/SmartThings/zwave-siren/fingerprints.yml b/drivers/SmartThings/zwave-siren/fingerprints.yml index 1e36e4af81..4b25211366 100644 --- a/drivers/SmartThings/zwave-siren/fingerprints.yml +++ b/drivers/SmartThings/zwave-siren/fingerprints.yml @@ -1,10 +1,16 @@ zwaveManufacturer: - - id: "Zooz" + - id: "Zooz/ZSE19" deviceLabel: Zooz Multisiren manufacturerId: 0x027A productType: 0x000C productId: 0x0003 deviceProfileName: multifunctional-siren + - id: "Zooz/ZSE50" + deviceLabel: Zooz ZSE50 Siren and Chime + manufacturerId: 0x027A + productType: 0x0004 + productId: 0x0369 + deviceProfileName: zooz-zse50 - id: "Everspring" deviceLabel: Everspring Siren manufacturerId: 0x0060 diff --git a/drivers/SmartThings/zwave-siren/profiles/zooz-zse50.yml b/drivers/SmartThings/zwave-siren/profiles/zooz-zse50.yml new file mode 100644 index 0000000000..866b88f436 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/profiles/zooz-zse50.yml @@ -0,0 +1,309 @@ +# Zooz ZSE50 Siren/Chime +# With deviceConfig - allows setting a tone from routines +name: zooz-zse50 +components: + - id: main + capabilities: + - id: alarm + version: 1 + - id: chime + version: 1 + - id: mode + version: 1 + - id: powerSource + version: 1 + - id: audioVolume + version: 1 + - id: battery + version: 1 + - id: firmwareUpdate + version: 1 + - id: refresh + version: 1 + categories: + - name: Siren +### PREFERENCES ### +preferences: + #param 1 + - name: "playbackMode" + title: "Playback Mode" + description: "* = Default; Set siren playback mode: once (0), loop for x seconds (1), loop x times (2), loop until cancel (3), no sound (4)." + required: false + preferenceType: enumeration + definition: + options: + 0: "Play once *" + 1: "Play in loop for set duration" + 2: "Play in loop for set number" + 3: "Play in loop until stopped" + 4: "No sound, LED only" + default: 0 + #param 2 + - name: "playbackDuration" + title: "Playback Duration" + description: "Default: 180; Set playback duration for the siren (in seconds) when the siren is in playback mode 1." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 900 + default: 180 + #param 3 + - name: "playbackLoop" + title: "Playback Loop Count" + description: "Default: 1; Set the number of playback loops for the selected tone when the siren is in playback mode 2." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 99 + default: 1 + #param 4 + - name: "playbackTone" + title: "Playback Tone" + description: "Set the default tone for the siren playback. Choose the number of the file in the library as value. Check the 'modes' list for the id numbers" + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 50 + default: 1 + #param 5 - playbackVolume ## Handled with volume command + #param 6 + - name: "ledMode" + title: "LED Indicator Mode" + description: "* = Default; Set the LED indicator mode for the siren: off (0), strobe (1), police strobe (2), pulse (3), solid on (4). See documentation for details." + required: false + preferenceType: enumeration + definition: + options: + 0: "LED always off" + 1: "LED strobe single color *" + 2: "LED strobe red and blue" + 3: "LED pulse single color" + 4: "LED solid on single color" + default: 1 + #param 7 + - name: "ledColor" + title: "LED Indicator Color" + description: "Default: 0; Set the LED indicator color: red (0), yellow (42), green (85), indigo (127), blue (170), purple (212), or white (255). More colors available through custom values corresponding to the color wheel. See advanced documentation for details." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 255 + default: 0 + #param 8 + - name: "lowBattery" + title: "Low Battery Report" + description: "Which % level should the device report low battery to the hub." + required: false + preferenceType: enumeration + definition: + options: + 10: "10% [DEFAULT]" + 15: "15%" + 20: "20%" + 25: "25%" + 30: "30%" + 35: "35%" + 40: "40%" + default: 10 + #param 9 + - name: "ledBatteryMode" + title: "LED In Back-Up Battery Mode" + description: "* = Default; Set the LED indicator in back-up battery mode: off (0), regular LED mode (1), pulse white for full battery and red for low battery (2)." + required: false + preferenceType: enumeration + definition: + options: + 0: "LED off" + 1: "Regular LED mode *" + 2: "Pulse white for full, red for low" + default: 1 + #param 10 + - name: "btnToneSelection" + title: "Button Tone Selection" + description: "Disable tone selection from physical buttons on the siren (0). When disabled, you'll only be able to program tones using the advanced parameters in the Z-Wave UI. Expert users only, see documentation for details." + required: false + preferenceType: enumeration + definition: + options: + 0: "Disabled" + 1: "Enabled [DEFAULT]" + default: 1 + #param 11 + - name: "btnVolSelection" + title: "Button Volume Selection" + description: "Disable volume adjustment from physical buttons on the siren (0). When disabled, you'll only be able to adjust volume using the advanced parameters in the Z-Wave UI. Expert users only, see documentation for details." + required: false + preferenceType: enumeration + definition: + options: + 0: "Disabled" + 1: "Enabled [DEFAULT]" + default: 1 + #param 13 + - name: "systemVolume" + title: "System Message Volume" + description: "Default: 50; Set system message volume (0-100, 0 – mute)." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 100 + default: 50 + #param 14 + - name: "ledBrightness" + title: "LED Indicator Brightness" + description: "Default: 5; Choose the LED indicator's brightness level (0 – off, 10 – high brightness)." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 10 + default: 5 + #param 15 + - name: "batteryFrequency" + title: "Battery Reporting Frequency" + description: "Default: 12; Set the reporting interval for battery (1-84 hours)." + required: false + preferenceType: integer + definition: + minimum: 1 + maximum: 84 + default: 12 + #param 16 + - name: "batteryThreshold" + title: "Battery Reporting Threshold" + description: "Default: 0; Set the threshold for battery reporting in % changes. Set to 0 to disable reporting based on threshold." + required: false + preferenceType: integer + definition: + minimum: 0 + maximum: 20 + default: 0 + +### DEVICE CONFIG ### +deviceConfig: + dashboard: + states: + - component: main + capability: chime + version: 1 + actions: + - component: main + capability: chime + version: 1 + basicPlus: [ ] + detailView: + - component: main + capability: alarm + version: 1 + values: + - key: alarm.value + enabledValues: + - 'off' + - 'both' + - key: "{{enumCommands}}" + enabledValues: + - 'off' + - 'both' + - component: main + capability: chime + version: 1 + - component: main + capability: mode + version: 1 + - component: main + capability: powerSource + version: 1 + values: + - key: powerSource.value + enabledValues: + - 'battery' + - 'mains' + - component: main + capability: audioVolume + version: 1 + - component: main + capability: battery + version: 1 + - component: main + capability: refresh + version: 1 + automation: + conditions: + - component: main + capability: alarm + version: 1 + values: + - key: alarm.value + enabledValues: + - 'off' + - 'both' + - component: main + capability: chime + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + command: setMode + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list + - component: main + capability: powerSource + version: 1 + values: + - key: powerSource.value + enabledValues: + - 'battery' + - 'mains' + step: 1 + - component: main + capability: audioVolume + version: 1 + - component: main + capability: battery + version: 1 + actions: + - component: main + capability: alarm + version: 1 + values: + - key: "{{enumCommands}}" + enabledValues: + - 'off' + - 'both' + - component: main + capability: chime + version: 1 + - component: main + capability: mode + version: 1 + patch: + - op: replace + path: /0/displayType + value: dynamicList + - op: add + path: /0/dynamicList + value: + value: mode.value + command: setMode + supportedValues: + value: supportedArguments.value + - op: remove + path: /0/list + - component: main + capability: audioVolume + version: 1 diff --git a/drivers/SmartThings/zwave-siren/src/init.lua b/drivers/SmartThings/zwave-siren/src/init.lua index b682ea77b7..52ccaba6b9 100644 --- a/drivers/SmartThings/zwave-siren/src/init.lua +++ b/drivers/SmartThings/zwave-siren/src/init.lua @@ -80,7 +80,8 @@ local driver_template = { capabilities.tamperAlert, capabilities.temperatureMeasurement, capabilities.relativeHumidityMeasurement, - capabilities.chime + capabilities.chime, + capabilities.powerSource }, sub_drivers = require("sub_drivers"), lifecycle_handlers = { diff --git a/drivers/SmartThings/zwave-siren/src/preferences.lua b/drivers/SmartThings/zwave-siren/src/preferences.lua index 2c10de6fcb..3cf2fe91b5 100644 --- a/drivers/SmartThings/zwave-siren/src/preferences.lua +++ b/drivers/SmartThings/zwave-siren/src/preferences.lua @@ -46,7 +46,32 @@ local devices = { PARAMETERS = { alarmLength = {parameter_number = 1, size = 2} } - } + }, + ZOOZ_ZSE50_SIREN = { + MATCHING_MATRIX = { + mfrs = 0x027A, + product_types = 0x0004, + product_ids = 0x0369 + }, + PARAMETERS = { + playbackMode = { parameter_number = 1, size = 1 }, + playbackDuration = { parameter_number = 2, size = 2 }, + playbackLoop = { parameter_number = 3, size = 1 }, + playbackTone = { parameter_number = 4, size = 1 }, + playbackVolume = { parameter_number = 5, size = 1 }, + ledMode = { parameter_number = 6, size = 1 }, + ledColor = { parameter_number = 7, size = 1 }, + lowBattery = { parameter_number = 8, size = 1 }, + ledBatteryMode = { parameter_number = 9, size = 1 }, + btnToneSelection = { parameter_number = 10, size = 1 }, + btnVolSelection = { parameter_number = 11, size = 1 }, + basicSetGrp2 = { parameter_number = 12, size = 1 }, --Not Used + systemVolume = { parameter_number = 13, size = 1 }, + ledBrightness = { parameter_number = 14, size = 1 }, + batteryFrequency = { parameter_number = 15, size = 1 }, + batteryThreshold = { parameter_number = 16, size = 1 } + } + }, } local preferences = {} @@ -70,4 +95,5 @@ preferences.to_numeric_value = function(new_value) end return numeric end + return preferences diff --git a/drivers/SmartThings/zwave-siren/src/sub_drivers.lua b/drivers/SmartThings/zwave-siren/src/sub_drivers.lua index a20d559e44..12ce423ba5 100644 --- a/drivers/SmartThings/zwave-siren/src/sub_drivers.lua +++ b/drivers/SmartThings/zwave-siren/src/sub_drivers.lua @@ -4,6 +4,7 @@ local lazy_load_if_possible = require "lazy_load_subdriver" local sub_drivers = { lazy_load_if_possible("multifunctional-siren"), + lazy_load_if_possible("zooz-zse50"), lazy_load_if_possible("zwave-sound-sensor"), lazy_load_if_possible("ecolink-wireless-siren"), lazy_load_if_possible("philio-sound-siren"), diff --git a/drivers/SmartThings/zwave-siren/src/test/test_zooz_zse50.lua b/drivers/SmartThings/zwave-siren/src/test/test_zooz_zse50.lua new file mode 100644 index 0000000000..1454458687 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/test/test_zooz_zse50.lua @@ -0,0 +1,717 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) +local SoundSwitch = (require "st.zwave.CommandClass.SoundSwitch")({ version = 1 }) +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 8 }) +local Version = (require "st.zwave.CommandClass.Version")({ version = 1 }) +local t_utils = require "integration_test.utils" + +local siren_endpoints = { + { + command_classes = { + { value = zw.SOUND_SWITCH }, + { value = zw.NOTIFICATION }, + { value = zw.VERSION }, + { value = zw.BASIC } + } + } +} + +--- { manufacturerId = 0x027A, productType = 0x0004, productId = 0x0369 } -- Zooz ZSE50 Siren & Chime +local mock_siren = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("zooz-zse50.yml"), + zwave_endpoints = siren_endpoints, + zwave_manufacturer_id = 0x027A, + zwave_product_type = 0x0004, + zwave_product_id = 0x0369, +}) + +local tones_list = { + [1] = { name = "test_tone1", duration = 2 }, + [2] = { name = "test_tone2", duration = 4 } +} + +local function test_init() + -- Initialize some fields to help with testing + mock_siren:set_field("TONE_DEFAULT", 1, { persist = true }) + mock_siren:set_field("TOTAL_TONES", 2, { persist = true }) + mock_siren:set_field("TONES_LIST", tones_list, { persist = true }) + + test.mock_device.add_test_device(mock_siren) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "init should rebuild tones when tone cache is missing", + function() + mock_siren:set_field("TONES_LIST", nil) + mock_siren:set_field("TONE_DEFAULT", nil) + + test.socket.device_lifecycle:__queue_receive({ mock_siren.id, "init" }) + test.socket.capability:__expect_send( + mock_siren:generate_test_message("main", capabilities.mode.mode("Rebuild List")) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonesNumberGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "added should set startup volume and refresh", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + test.socket.zwave:__set_channel_ordering("relaxed") + + test.socket.device_lifecycle:__queue_receive({ mock_siren.id, "added" }) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 10 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Version:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Notification:Get({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.STATE_IDLE, + v1_alarm_type = 0 + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationGet({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "infoChanged should update config and send delayed Basic Set", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.device_lifecycle:__queue_receive( + mock_siren:generate_info_changed({ preferences = { ledColor = 255 } }) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Configuration:Set({ parameter_number = 7, size = 1, configuration_value = -1 }) + ) + ) + + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Set({ value = 0x00 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "infoChanged should update playbackDuration and send delayed Basic Set", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.device_lifecycle:__queue_receive( + mock_siren:generate_info_changed({ preferences = { playbackDuration = 90 } }) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Configuration:Set({ parameter_number = 2, size = 2, configuration_value = 90 }) + ) + ) + + test.mock_time.advance_time(1) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Set({ value = 0x00 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Version report should update firmware version", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(Version:Report({ + application_version = 2, + application_sub_version = 5 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.firmwareUpdate.currentVersion({ value = "2.05" })) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Notification report AC_MAINS_DISCONNECTED should set power source to battery", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(Notification:Report({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.AC_MAINS_DISCONNECTED + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.powerSource.powerSource.battery()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Notification report AC_MAINS_RE_CONNECTED should set power source to mains", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(Notification:Report({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.AC_MAINS_RE_CONNECTED + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.powerSource.powerSource.mains()) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "SoundSwitch ConfigurationReport should update volume", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:ConfigurationReport({ + volume = 75, + default_tone_identifer = 5 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.audioVolume.volume(75)) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "SoundSwitch TonesNumberReport should request info on each tone", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:TonesNumberReport({ + supported_tones = 2 + })) } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ToneInfoGet({ tone_identifier = 1 }) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ToneInfoGet({ tone_identifier = 2 }) + ) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "SoundSwitch ToneInfoReport should update supported modes when all tones received", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:ToneInfoReport({ + tone_identifier = 1, + name = "test_tone1", + tone_duration = 2 + })) } + }, + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:ToneInfoReport({ + tone_identifier = 2, + name = "test_tone2", + tone_duration = 4 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.supportedModes({ "Rebuild List", "Off", "1: test_tone1 (2s)", "2: test_tone2 (4s)" })) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.supportedArguments({ "Off", "1: test_tone1 (2s)", "2: test_tone2 (4s)" })) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "SoundSwitch TonePlayReport for tone 1 should set alarm on, chime on, and mode to tone name", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:TonePlayReport({ + tone_identifier = 1 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.both()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.chime.chime.chime()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.mode("1: test_tone1 (2s)")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "SoundSwitch TonePlayReport for tone 0 should set alarm off, chime off, and mode Off", + { + { + channel = "zwave", + direction = "receive", + message = { mock_siren.id, zw_test_utils.zwave_test_build_receive_command(SoundSwitch:TonePlayReport({ + tone_identifier = 0 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.chime.chime.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.mode("Off")) + } + }, + { + min_api_version = 17 + } +) + +test.register_message_test( + "Basic report 0x00 should be handled as alarm off, chime off, and mode Off", + { + { + channel = "zwave", + direction = "receive", + message = { + mock_siren.id, + zw_test_utils.zwave_test_build_receive_command(Basic:Report({ value = 0 })) } + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.alarm.alarm.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.chime.chime.off()) + }, + { + channel = "capability", + direction = "send", + message = mock_siren:generate_test_message("main", capabilities.mode.mode("Off")) + } + }, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "volumeUp should increase volume by 2", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "audioVolume", component = "main", command = "volumeUp", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 52 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "volumeUp should decrease volume by 2", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "audioVolume", component = "main", command = "volumeDown", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 48 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setVolume should set volume to specified value", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "audioVolume", component = "main", command = "setVolume", args = { 75 } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationSet({ volume = 75 }) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "alarm.both() should send TonePlaySet with default tone and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "alarm", component = "main", command = "both", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0xFF }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "alarm.off() should send TonePlaySet with tone 0x00 and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "alarm", component = "main", command = "off", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0x00 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "chime.chime() should send TonePlaySet with default tone and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "chime", component = "main", command = "chime", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0xFF }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "chime.off() should send TonePlaySet with tone 0x00 and TonePlayGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "chime", component = "main", command = "off", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0x00 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setMode should play the specified tone", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "mode", component = "main", command = "setMode", args = { "1: test_tone1 (2s)" } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 1 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setMode to Off should turn off the tone", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "mode", component = "main", command = "setMode", args = { "Off" } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlaySet({ tone_identifier = 0x00 }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "setMode to Rebuild List should emit mode and send TonesNumberGet", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "mode", component = "main", command = "setMode", args = { "Rebuild List" } } + }) + test.socket.capability:__expect_send( + mock_siren:generate_test_message("main", capabilities.mode.mode("Rebuild List")) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonesNumberGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.register_coroutine_test( + "refresh should send a series of Z-Wave Gets", + function() + test.socket.capability:__queue_receive({ + mock_siren.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Basic:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Version:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + Notification:Get({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.STATE_IDLE, + v1_alarm_type = 0 + }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:ConfigurationGet({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_siren, + SoundSwitch:TonePlayGet({}) + ) + ) + end, + { + min_api_version = 17 + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-siren/src/zooz-zse50/can_handle.lua b/drivers/SmartThings/zwave-siren/src/zooz-zse50/can_handle.lua new file mode 100644 index 0000000000..90ce30f76b --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zooz-zse50/can_handle.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local function can_handle_multifunctional_siren(opts, driver, device, ...) + local FINGERPRINTS = require("zooz-zse50.fingerprints") + for _, fingerprint in ipairs(FINGERPRINTS) do + if device:id_match(fingerprint.manufacturerId, fingerprint.productType, fingerprint.productId) then + return true, require("zooz-zse50") + end + end + return false +end + +return can_handle_multifunctional_siren diff --git a/drivers/SmartThings/zwave-siren/src/zooz-zse50/fingerprints.lua b/drivers/SmartThings/zwave-siren/src/zooz-zse50/fingerprints.lua new file mode 100644 index 0000000000..a8c37a04e6 --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zooz-zse50/fingerprints.lua @@ -0,0 +1,8 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local ZSE50_FINGERPRINTS = { + { manufacturerId = 0x027A, productType = 0x0004, productId = 0x0369 } -- Zooz ZSE50 Siren & Chime +} + +return ZSE50_FINGERPRINTS diff --git a/drivers/SmartThings/zwave-siren/src/zooz-zse50/init.lua b/drivers/SmartThings/zwave-siren/src/zooz-zse50/init.lua new file mode 100644 index 0000000000..a1d58b262a --- /dev/null +++ b/drivers/SmartThings/zwave-siren/src/zooz-zse50/init.lua @@ -0,0 +1,367 @@ +-- Copyright 2026 SmartThings +-- Licensed under the Apache License, Version 2.0 + +local preferencesMap = require "preferences" + +local log = require "log" +local st_utils = require "st.utils" +local capabilities = require "st.capabilities" +local defaults = require "st.zwave.defaults" + +local cc = require "st.zwave.CommandClass" +local Basic = (require "st.zwave.CommandClass.Basic")({ version = 1 }) +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version = 4 }) +local Notification = (require "st.zwave.CommandClass.Notification")({ version = 8 }) +local SoundSwitch = (require "st.zwave.CommandClass.SoundSwitch")({ version = 1 }) +local Version = (require "st.zwave.CommandClass.Version")({ version = 1 }) + +--- @param self st.zwave.Driver +--- @param device st.zwave.Device +local function update_firmwareUpdate_capability(self, device, component, major, minor) + if device:supports_capability_by_id(capabilities.firmwareUpdate.ID, component.id) then + local fmtFirmwareVersion = string.format("%d.%02d", major, minor) + device:emit_component_event(component, capabilities.firmwareUpdate.currentVersion({ value = fmtFirmwareVersion })) + end +end + +--- Update the built in capability firmwareUpdate's currentVersion attribute with the +--- Zwave version information received during pairing of the device. +--- @param self st.zwave.Driver +--- @param device st.zwave.Device +local function updateFirmwareVersion(self, device) + local fw_major = (((device.st_store or {}).zwave_version or {}).firmware or {}).major + local fw_minor = (((device.st_store or {}).zwave_version or {}).firmware or {}).minor + if fw_major and fw_minor then + update_firmwareUpdate_capability(self, device, device.profile.components.main, fw_major, fw_minor) + else + device.log.warn("Firmware major or minor version not available.") + end +end + +local function getModeName(toneId, toneInfo) + return string.format("%s: %s (%ss)", toneId, toneInfo.name, toneInfo.duration) +end + +local function playTone(device, tone_id) + local tones_list = device:get_field("TONES_LIST") + local default_tone = device:get_field("TONE_DEFAULT") or 1 + local playbackMode = tonumber(device.preferences.playbackMode) + local duration = 0 + + if playbackMode == 1 then + duration = device.preferences.playbackDuration + elseif playbackMode == 2 then + duration = duration * device.preferences.playbackLoop + elseif tones_list ~= nil and tone_id > 0 then + if tone_id == 0xFF then + duration = tones_list[tonumber(default_tone)].duration + else + duration = tones_list[tonumber(tone_id)].duration + end + end + + log.info(string.format("Playing Tone: %s, playbackMode %s, duration %ss", tone_id, playbackMode, duration)) + + device:send(SoundSwitch:TonePlaySet({ tone_identifier = tone_id })) + device:send(SoundSwitch:TonePlayGet({})) + + local soundSwitch_refresh = function() + local chime = device:get_latest_state("main", capabilities.chime.ID, capabilities.chime.chime.NAME) + local mode = device:get_latest_state("main", capabilities.mode.ID, capabilities.mode.mode.NAME) + log.info(string.format("Running SoundSwitch Refresh: %s | %s", chime, mode)) + if chime ~= "off" or mode ~= "Off" then + device:send(SoundSwitch:TonePlayGet({})) + end + end + + if tone_id > 0 and playbackMode <= 2 then + local minDuration = math.max(duration, 4) + device.thread:call_with_delay(minDuration + 0.5, soundSwitch_refresh) + device.thread:call_with_delay(minDuration + 4, soundSwitch_refresh) + end + +end + +local function rebuildTones(device) + device:emit_event(capabilities.mode.mode("Rebuild List")) + device:send(SoundSwitch:TonesNumberGet({})) +end + +local function refresh_handler(self, device) + device:default_refresh() + device:send(Version:Get({})) + device:send(Notification:Get({ + notification_type = Notification.notification_type.POWER_MANAGEMENT, + event = Notification.event.power_management.STATE_IDLE, + v1_alarm_type = 0 + })) + device:send(SoundSwitch:ConfigurationGet({})) + device:send(SoundSwitch:TonePlayGet({})) +end + +local function setMode_handler(self, device, command) + local mode_value = command.args.mode + local mode_split = string.find(mode_value, ":") + + if mode_split ~= nil then + mode_value = string.sub(mode_value, 1, mode_split - 1) + end + log.info(string.format("Command: setMode (%s)", mode_value)) + + if mode_value == 'Rebuild List' then + rebuildTones(device) + elseif mode_value == 'Off' then + playTone(device, 0x00) + else + playTone(device, tonumber(mode_value)) + end +end + +local function setVolume_handler(self, device, cmd) + local new_volume = st_utils.clamp_value(cmd.args.volume, 0, 100) + device:send(SoundSwitch:ConfigurationSet({ volume = new_volume })) +end + +local function volumeUp_handler(self, device, cmd) + local volume = device:get_latest_state("main", capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) or 50 + volume = st_utils.clamp_value(volume + 2, 0, 100) + device:send(SoundSwitch:ConfigurationSet({ volume = volume })) +end + +local function volumeDown_handler(self, device, cmd) + local volume = device:get_latest_state("main", capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) or 50 + volume = st_utils.clamp_value(volume - 2, 0, 100) + device:send(SoundSwitch:ConfigurationSet({ volume = volume })) +end + +local function tone_on(self, device) + playTone(device, 0xFF) +end + +local function tone_off(self, device) + playTone(device, 0x00) +end + +local function tones_number_report_handler(self, device, cmd) + local total_tones = cmd.args.supported_tones + + --Max 50 tones per Zooz settings + if total_tones > 50 then + total_tones = 50 + end + + local tones_list = { } + device:set_field("TOTAL_TONES", total_tones) + device:set_field("TONES_LIST_TMP", tones_list) + + --Get info on all tones + for tone = 1, total_tones do + device:send(SoundSwitch:ToneInfoGet({ tone_identifier = tone })) + end +end + +local function tone_info_report_handler(self, device, cmd) + local tone_id = tonumber(cmd.args.tone_identifier) + local tone_name = cmd.args.name + local duration = cmd.args.tone_duration + local total_tones = device:get_field("TOTAL_TONES") + local tones_list = device:get_field("TONES_LIST_TMP") or {} + + tones_list[tone_id] = { name = tone_name, duration = duration } + device:set_field("TONES_LIST_TMP", tones_list) + + if tone_id >= total_tones or #tones_list >= total_tones then + log.info(string.format("Received info on all tones: tone_id %s, #tones_list %s, total_tones %s", tone_id, #tones_list, total_tones)) + + local tones_arguments = { "Off" } + for il, vl in ipairs(tones_list) do + table.insert(tones_arguments, getModeName(il, vl)) + end + + device:set_field("TONES_LIST", tones_list, { persist = true }) + device:emit_event(capabilities.mode.supportedModes({ "Rebuild List", table.unpack(tones_arguments) })) + device:emit_event(capabilities.mode.supportedArguments(tones_arguments)) + device:send(SoundSwitch:TonePlayGet({})) + end +end + +--- Handle when tone is played (TONE_PLAY_REPORT or BASIC_REPORT) +local function tone_playing(self, device, tone_id) + local tones_list = device:get_field("TONES_LIST") + + if tones_list == nil or tones_list == {} then + rebuildTones(device) + end + + if tone_id == 0 then + device:emit_event(capabilities.alarm.alarm.off()) + device:emit_event(capabilities.chime.chime.off()) + device:emit_event(capabilities.mode.mode("Off")) + else + local toneInfo = (tones_list or {})[tone_id] or { name = "Unknown", duration = "0" } + local modeName = getModeName(tone_id, toneInfo) + device:emit_event(capabilities.alarm.alarm.both()) + device:emit_event(capabilities.chime.chime.chime()) + device:emit_event(capabilities.mode.mode(modeName)) + end +end + +local function tone_play_report_handler(self, device, cmd) + local tone_id = tonumber(cmd.args.tone_identifier) + tone_playing(self, device, tone_id) +end + +local function basic_report_handler(self, device, cmd) + local tone_id = tonumber(cmd.args.value) + tone_playing(self, device, tone_id) +end + +--- Handle SoundSwitch Config Reports (volume) +local function soundSwitch_configuration_report(self, device, cmd) + local volume = st_utils.clamp_value(cmd.args.volume, 0, 100) + local default_tone = cmd.args.default_tone_identifer + device:emit_event(capabilities.audioVolume.volume(volume)) + device:set_field("TONE_DEFAULT", default_tone, { persist = true }) +end + +--- Handle power source changes +local function notification_report_handler(self, device, cmd) + if cmd.args.notification_type == Notification.notification_type.POWER_MANAGEMENT then + local event = cmd.args.event + local powerManagement = Notification.event.power_management + + if event == powerManagement.AC_MAINS_DISCONNECTED then + device:emit_event(capabilities.powerSource.powerSource.battery()) + elseif event == powerManagement.AC_MAINS_RE_CONNECTED or event == powerManagement.STATE_IDLE then + device:emit_event(capabilities.powerSource.powerSource.mains()) + end + end +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param cmd st.zwave.CommandClass.Version.Report +local function version_report_handler(driver, device, cmd) + local major = cmd.args.application_version + local minor = cmd.args.application_sub_version + + -- Update the built in firmware capability, if available + update_firmwareUpdate_capability(driver, device, device.profile.components.main, major, minor) +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +local function device_init(driver, device) + if (device:get_field("TONES_LIST") == nil or device:get_field("TONE_DEFAULT") == nil) then + rebuildTones(device) + end +end + +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +local function device_added(driver, device) + device:send(SoundSwitch:ConfigurationSet({ volume = 10 })) + updateFirmwareVersion(driver, device) + device:refresh() +end + +--- Handle preference changes (same as default but added hack for unsigned parameters) +--- @param driver st.zwave.Driver +--- @param device st.zwave.Device +--- @param event table +--- @param args +local function info_changed(driver, device, event, args) + local preferences = preferencesMap.get_device_parameters(device) + + if preferences then + local did_configuration_change = false + for id, value in pairs(device.preferences) do + if args.old_st_store.preferences[id] ~= value and preferences[id] then + local new_parameter_value = preferencesMap.to_numeric_value(device.preferences[id]) + --Hack to convert to signed integer + local size_factor = math.floor(256 ^ preferences[id].size) + if new_parameter_value >= (size_factor / 2) then + new_parameter_value = new_parameter_value - size_factor + end + --END Hack + device:send(Configuration:Set({ parameter_number = preferences[id].parameter_number, size = preferences[id].size, configuration_value = new_parameter_value })) + did_configuration_change = true + end + end + + if did_configuration_change then + local delayed_command = function() + device:send(Basic:Set({ value = 0x00 })) + end + device.thread:call_with_delay(1, delayed_command) + end + + end +end + +local zooz_zse50 = { + NAME = "Zooz ZSE50", + can_handle = require("zooz-zse50.can_handle"), + + supported_capabilities = { + capabilities.battery, + capabilities.chime, + capabilities.mode, + capabilities.audioVolume, + capabilities.powerSource, + capabilities.firmwareUpdate, + capabilities.configuration, + capabilities.refresh + }, + + zwave_handlers = { + [cc.BASIC] = { + [Basic.REPORT] = basic_report_handler + }, + [cc.SOUND_SWITCH] = { + [SoundSwitch.TONES_NUMBER_REPORT] = tones_number_report_handler, + [SoundSwitch.TONE_INFO_REPORT] = tone_info_report_handler, + [SoundSwitch.TONE_PLAY_REPORT] = tone_play_report_handler, + [SoundSwitch.CONFIGURATION_REPORT] = soundSwitch_configuration_report + }, + [cc.NOTIFICATION] = { + [Notification.REPORT] = notification_report_handler + }, + [cc.VERSION] = { + [Version.REPORT] = version_report_handler + } + }, + + capability_handlers = { + [capabilities.mode.ID] = { + [capabilities.mode.commands.setMode.NAME] = setMode_handler + }, + [capabilities.audioVolume.ID] = { + [capabilities.audioVolume.commands.setVolume.NAME] = setVolume_handler, + [capabilities.audioVolume.commands.volumeUp.NAME] = volumeUp_handler, + [capabilities.audioVolume.commands.volumeDown.NAME] = volumeDown_handler + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_handler + }, + [capabilities.alarm.ID] = { + [capabilities.alarm.commands.both.NAME] = tone_on, + [capabilities.alarm.commands.off.NAME] = tone_off + }, + [capabilities.chime.ID] = { + [capabilities.chime.commands.chime.NAME] = tone_on, + [capabilities.chime.commands.off.NAME] = tone_off + }, + + }, + + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = info_changed + } +} + +defaults.register_for_default_handlers(zooz_zse50, zooz_zse50.supported_capabilities) + +return zooz_zse50 diff --git a/drivers/SmartThings/zwave-switch/fingerprints.yml b/drivers/SmartThings/zwave-switch/fingerprints.yml index 46faa7d106..d0d10f2e5a 100644 --- a/drivers/SmartThings/zwave-switch/fingerprints.yml +++ b/drivers/SmartThings/zwave-switch/fingerprints.yml @@ -56,6 +56,12 @@ zwaveManufacturer: productType: 0x0003 productId: 0x0001 deviceProfileName: inovelli-dimmer + - id: "Inovelli/VZW31-SN" + deviceLabel: Inovelli Dimmer Red Series + manufacturerId: 0x031E + productType: 0x0015 + productId: 0x0001 + deviceProfileName: inovelli-dimmer-vzw31-sn - id: "Inovelli/VZW32-SN" deviceLabel: Inovelli mmWave Dimmer Red Series manufacturerId: 0x031E @@ -916,6 +922,12 @@ zwaveManufacturer: manufacturerId: 0x010F productType: 0x0102 deviceProfileName: fibaro-dimmer-2 + - id: 013C/0005/008A + deviceLabel: Philio Dimmer Switch PAD19 + manufacturerId: 0x013C + productType: 0x0005 + productId: 0x008A + deviceProfileName: switch-level #Zooz - id: "Zooz/ZEN05" deviceLabel: Zooz Outdoor Plug ZEN05 diff --git a/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-vzw31-sn.yml b/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-vzw31-sn.yml new file mode 100644 index 0000000000..e2da4a3239 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/profiles/inovelli-dimmer-vzw31-sn.yml @@ -0,0 +1,284 @@ +name: inovelli-dimmer-vzw31-sn +components: +- id: main + capabilities: + - id: switch + version: 1 + - id: switchLevel + version: 1 + - id: powerMeter + version: 1 + - id: energyMeter + version: 1 + - id: refresh + version: 1 + categories: + - name: Switch +- id: button1 + label: Down Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button2 + label: Up Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +- id: button3 + label: Config Button + capabilities: + - id: button + version: 1 + categories: + - name: RemoteController +preferences: + - name: "notificationChild" + title: "Add Child Device - Notification" + description: "Create Separate Child Device for Notification Control" + required: false + preferenceType: boolean + definition: + default: false + - name: "notificationType" + title: "Notification Effect" + description: "This is the notification effect used by the notification child device" + required: false + preferenceType: enumeration + definition: + options: + "255": "Clear" + "1": "Solid" + "2": "Fast Blink" + "3": "Slow Blink" + "4": "Pulse" + "5": "Chase" + "6": "Open/Close" + "7": "Small-to-Big" + "8": "Aurora" + "9": "Slow Falling" + "10": "Medium Falling" + "11": "Fast Falling" + "12": "Slow Rising" + "13": "Medium Rising" + "14": "Fast Rising" + "15": "Medium Blink" + "16": "Slow Chase" + "17": "Fast Chase" + "18": "Fast Siren" + "19": "Slow Siren" + default: 1 + - name: "parameter158" + title: "158. Switch Mode" + description: "Use as a Dimmer or an On/Off switch" + required: true + preferenceType: enumeration + definition: + options: + "0": "Dimmer (default)" + "1": "On/Off" + default: 0 + - name: "parameter52" + title: "52. Smart Bulb Mode" + description: "For use with Smart Bulbs that need constant power and are controlled via commands rather than power. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: true + preferenceType: enumeration + definition: + options: + "0": "Disabled (default)" + "1": "Smart Bulb Mode" + default: 0 + - name: "parameter22" + title: "22. Aux Switch Type" + description: "Set the Aux switch type. Smart Bulb Mode does not work in Dumb 3-Way Switch mode." + required: true + preferenceType: enumeration + definition: + options: + "0": "None (default)" + "1": "3-Way Dumb Switch" + "2": "3-Way Aux Switch" + "3": "Single Pole Full Sine Wave" + default: 0 + - name: "parameter1" + title: "1. Dimming Speed (Remote)" + description: "This changes the speed that the light dims up when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + Default=25 (2500ms or 2.5s)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 25 + - name: "parameter2" + title: "2. Dimming Speed (Local)" + description: "This changes the speed that the light dims up when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter3" + title: "3. Ramp Rate (Remote)" + description: "This changes the speed that the light turns on when controlled from the hub. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 1)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter4" + title: "4. Ramp Rate (Local)" + description: "This changes the speed that the light turns on when controlled at the switch. A setting of '0' turns the light immediately on. Increasing the value slows down the transition speed. Value is multiplied by 100ms. + (i.e 25 = 2500ms or 2.5s) Default=255 (Sync with parameter 3)" + required: false + preferenceType: number + definition: + minimum: 0 + maximum: 255 + default: 255 + - name: "parameter9" + title: "9. Minimum Level" + description: "The minimum level that the light can be dimmed. Useful when the user has a light that does not turn on or flickers at a lower level." + required: true + preferenceType: number + definition: + minimum: 1 + maximum: 99 + default: 1 + - name: "parameter10" + title: "10. Maximum Level" + description: "The maximum level that the light can be dimmed. Useful when the user wants to limit the maximum brighness." + required: true + preferenceType: number + definition: + minimum: 2 + maximum: 100 + default: 100 + - name: "parameter15" + title: "15. Level After Power Restored" + description: "The level the switch will return to when power is restored after power failure. + 0=Off + 1-100=Set Level + 101=Use previous level." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 101 + default: 101 + - name: "parameter18" + title: "18. Active Power Reports" + description: "Power level change that will result in a new power report being sent. + 0 = Disabled + 1-32767 = 0.1W-3276.7W." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 100 + - name: "parameter19" + title: "19. Periodic Power & Energy Reports" + description: "Time period between consecutive power & energy reports being sent (in seconds). The timer is reset after each report is sent." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 3600 + - name: "parameter20" + title: "20. Active Energy Reports" + description: "Energy level change that will result in a new energy report being sent. + 0 = Disabled + 1-32767 = 0.01kWh-327.67kWh." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 32767 + default: 100 + - name: "parameter50" + title: "50. Button Press Delay" + description: "Adjust the delay used in scene control. 0=no delay (disables multi-tap scenes), 1=100ms, 2=200ms, 3=300ms, etc." + required: true + preferenceType: enumeration + definition: + options: + "0": "0ms" + "1": "100ms" + "2": "200ms" + "3": "300ms" + "4": "400ms" + "5": "500ms (default)" + "6": "600ms" + "7": "700ms" + "8": "800ms" + "9": "900ms" + default: 5 + - name: "parameter95" + title: "95. LED Indicator Color (w/On)" + description: "Set the color of the Full LED Indicator when the load is on." + required: true + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter96" + title: "96. LED Indicator Color (w/Off)" + description: "Set the color of the Full LED Indicator when the load is off." + required: true + preferenceType: enumeration + definition: + options: + "0": "Red" + "7": "Orange" + "28": "Lemon" + "64": "Lime" + "85": "Green" + "106": "Teal" + "127": "Cyan" + "148": "Aqua" + "170": "Blue (default)" + "190": "Violet" + "212": "Magenta" + "234": "Pink" + "255": "White" + default: 170 + - name: "parameter97" + title: "97. LED Indicator Intensity (w/On)" + description: "Set the intensity of the Full LED Indicator when the load is on." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 50 + - name: "parameter98" + title: "98. LED Indicator Intensity (w/Off)" + description: "Set the intensity of the Full LED Indicator when the load is off." + required: true + preferenceType: number + definition: + minimum: 0 + maximum: 100 + default: 5 \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua b/drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua index 7c0f6be77b..c8ba7ac681 100644 --- a/drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua +++ b/drivers/SmartThings/zwave-switch/src/inovelli/can_handle.lua @@ -3,6 +3,7 @@ local INOVELLI_FINGERPRINTS = { { mfr = 0x031E, prod = 0x0017, model = 0x0001 }, -- Inovelli VZW32-SN + { mfr = 0x031E, prod = 0x0015, model = 0x0001 }, -- Inovelli VZW31-SN { mfr = 0x031E, prod = 0x0001, model = 0x0001 }, -- Inovelli LZW31SN { mfr = 0x031E, prod = 0x0003, model = 0x0001 }, -- Inovelli LZW31 } @@ -17,4 +18,4 @@ local function can_handle_inovelli(opts, driver, device, ...) return false end -return can_handle_inovelli +return can_handle_inovelli \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua b/drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua index e182120ece..2fdea81379 100644 --- a/drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua +++ b/drivers/SmartThings/zwave-switch/src/inovelli/sub_drivers.lua @@ -5,5 +5,6 @@ local lazy_load = require "lazy_load_subdriver" return { lazy_load("inovelli.lzw31-sn"), + lazy_load("inovelli.vzw31-sn"), lazy_load("inovelli.vzw32-sn") } diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/can_handle.lua b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/can_handle.lua new file mode 100644 index 0000000000..2446c06dde --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/can_handle.lua @@ -0,0 +1,19 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_DIMMER_PRODUCT_ID = 0x0001 + +local function can_handle_vzw31_sn(opts, driver, device, ...) + if device:id_match( + INOVELLI_MANUFACTURER_ID, + INOVELLI_VZW31_SN_PRODUCT_TYPE, + INOVELLI_DIMMER_PRODUCT_ID + ) then + return true, require("inovelli.vzw31-sn") + end + return false +end + +return can_handle_vzw31_sn diff --git a/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/init.lua b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/init.lua new file mode 100644 index 0000000000..1dec88e745 --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/inovelli/vzw31-sn/init.lua @@ -0,0 +1,77 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local capabilities = require "st.capabilities" +--- @type st.zwave.CommandClass.SwitchMultilevel +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) +--- @type st.zwave.CommandClass.Meter +local Meter = (require "st.zwave.CommandClass.Meter")({ version = 3 }) +--- @type st.zwave.CommandClass.Association +local Association = (require "st.zwave.CommandClass.Association")({ version = 1 }) +--- @type st.device +local st_device = require "st.device" +local cc = require "st.zwave.CommandClass" + + +local supported_button_values = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} + + +local function refresh_handler(driver, device) + device:send(SwitchMultilevel:Get({})) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.WATTS })) + device:send(Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS })) +end + +local function device_added(driver, device) + if device.network_type ~= st_device.NETWORK_TYPE_CHILD then + device:send(Association:Set({grouping_identifier = 1, node_ids = {driver.environment_info.hub_zwave_id}})) + for _, component in pairs(device.profile.components) do + if component.id ~= "main" and component.id ~= "LEDColorConfiguration" then + device:emit_component_event( + component, + capabilities.button.supportedButtonValues( + supported_button_values, + { visibility = { displayed = false } } + ) + ) + device:emit_component_event( + component, + capabilities.button.numberOfButtons({value = 1}, { visibility = { displayed = false } }) + ) + end + end + refresh_handler(driver, device) + else + device:emit_event(capabilities.colorControl.hue(1)) + device:emit_event(capabilities.colorControl.saturation(1)) + device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + device:emit_event(capabilities.switchLevel.level(100)) + device:emit_event(capabilities.switch.switch("off")) + end +end + +local function onoff_level_report_handler(driver, device, cmd) + local value = cmd.args.target_value and cmd.args.target_value or cmd.args.value + device:emit_event(value == 0 and capabilities.switch.switch.off() or capabilities.switch.switch.on()) + device:emit_event(capabilities.switchLevel.level(value)) +end + +local vzw31_sn = { + NAME = "Inovelli VZW31-SN Dimmer", + lifecycle_handlers = { + added = device_added, + }, + zwave_handlers = { + [cc.SWITCH_MULTILEVEL] = { + [SwitchMultilevel.REPORT] = onoff_level_report_handler + } + }, + capability_handlers = { + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_handler + } + }, + can_handle = require("inovelli.vzw31-sn.can_handle") +} + +return vzw31_sn \ No newline at end of file diff --git a/drivers/SmartThings/zwave-switch/src/preferences.lua b/drivers/SmartThings/zwave-switch/src/preferences.lua index 798efaddf0..09155cdd7d 100644 --- a/drivers/SmartThings/zwave-switch/src/preferences.lua +++ b/drivers/SmartThings/zwave-switch/src/preferences.lua @@ -59,6 +59,33 @@ local devices = { switchType = {parameter_number = 22, size = 1} } }, + INOVELLI_VZW31_SN = { + MATCHING_MATRIX = { + mfrs = 0x031E, + product_types = {0x0015}, + product_ids = 0x0001 + }, + PARAMETERS = { + parameter158 = {parameter_number = 158, size = 1}, + parameter52 = {parameter_number = 52, size = 1}, + parameter1 = {parameter_number = 1, size = 1}, + parameter2 = {parameter_number = 2, size = 1}, + parameter3 = {parameter_number = 3, size = 1}, + parameter4 = {parameter_number = 4, size = 1}, + parameter9 = {parameter_number = 9, size = 1}, + parameter10 = {parameter_number = 10, size = 1}, + parameter15 = {parameter_number = 15, size = 1}, + parameter18 = {parameter_number = 18, size = 1}, + parameter19 = {parameter_number = 19, size = 2}, + parameter20 = {parameter_number = 20, size = 2}, + parameter22 = {parameter_number = 22, size = 1}, + parameter50 = {parameter_number = 50, size = 1}, + parameter95 = {parameter_number = 95, size = 1}, + parameter96 = {parameter_number = 96, size = 1}, + parameter97 = {parameter_number = 97, size = 1}, + parameter98 = {parameter_number = 98, size = 1}, + } + }, INOVELLI_VZW32_SN = { MATCHING_MATRIX = { mfrs = 0x031E, diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn.lua new file mode 100644 index 0000000000..a372a94eec --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn.lua @@ -0,0 +1,287 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local SwitchBinary = (require "st.zwave.CommandClass.SwitchBinary")({version=2}) +local SwitchMultilevel = (require "st.zwave.CommandClass.SwitchMultilevel")({version=4}) +local Basic = (require "st.zwave.CommandClass.Basic")({version=1}) +local CentralScene = (require "st.zwave.CommandClass.CentralScene")({version=3}) +local Association = (require "st.zwave.CommandClass.Association")({version=1}) +local Meter = (require "st.zwave.CommandClass.Meter")({version=3}) +local t_utils = require "integration_test.utils" + +-- Inovelli VZW31-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_VZW31_SN_PRODUCT_ID = 0x0001 +local LED_BAR_COMPONENT_NAME = "LEDColorConfiguration" + +-- Device endpoints with supported command classes +local inovelli_vzw31_sn_endpoints = { + { + command_classes = { + {value = zw.SWITCH_BINARY}, + {value = zw.SWITCH_MULTILEVEL}, + {value = zw.BASIC}, + {value = zw.CONFIGURATION}, + {value = zw.CENTRAL_SCENE}, + {value = zw.ASSOCIATION}, + {value = zw.METER}, + } + } +} + +-- Create mock device +local mock_inovelli_vzw31_sn = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-dimmer-vzw31-sn.yml"), + zwave_endpoints = inovelli_vzw31_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW31_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW31_SN_PRODUCT_ID +}) + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzw31_sn) +end +test.set_test_init_function(test_init) + +local supported_button_values = {"pushed","held","down_hold","pushed_2x","pushed_3x","pushed_4x","pushed_5x"} + +-- Test device initialization +test.register_coroutine_test( + "Device should initialize properly on added lifecycle event", + function() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.device_lifecycle:__queue_receive({ mock_inovelli_vzw31_sn.id, "added" }) + + for button_name, _ in pairs(mock_inovelli_vzw31_sn.profile.components) do + if button_name ~= "main" and button_name ~= LED_BAR_COMPONENT_NAME then + test.socket.capability:__expect_send( + mock_inovelli_vzw31_sn:generate_test_message( + button_name, + capabilities.button.supportedButtonValues( + supported_button_values, + { visibility = { displayed = false } } + ) + ) + ) + test.socket.capability:__expect_send( + mock_inovelli_vzw31_sn:generate_test_message( + button_name, + capabilities.button.numberOfButtons({ value = 1 }, { visibility = { displayed = false } }) + ) + ) + end + end + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Association:Set({ + grouping_identifier = 1, + node_ids = {}, -- Mock hub Z-Wave ID + payload = "\x01", -- Should contain grouping_identifier = 1 + }) + ) + ) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + ) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }) + ) + ) + end +) + +-- Test switch on command +test.register_coroutine_test( + "Switch on command should send Basic Set with ON value", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_inovelli_vzw31_sn.id, + { capability = "switch", command = "on", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Basic:Set({ value = SwitchBinary.value.ON_ENABLE }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + end +) + +-- Test switch off command +test.register_coroutine_test( + "Switch off command should send Basic Set with OFF value", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + test.socket.capability:__queue_receive({ + mock_inovelli_vzw31_sn.id, + { capability = "switch", command = "off", args = {} } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Basic:Set({ value = SwitchBinary.value.OFF_DISABLE }) + ) + ) + test.wait_for_events() + test.mock_time.advance_time(3) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + end +) + +-- Test switch level command +test.register_coroutine_test( + "Switch level command should send SwitchMultilevel Set", + function() + test.timer.__create_and_queue_test_time_advance_timer(3, "oneshot") + + test.socket.capability:__queue_receive({ + mock_inovelli_vzw31_sn.id, + { capability = "switchLevel", command = "setLevel", args = { 50 } } + }) + + local expected_command = SwitchMultilevel:Set({ value = 50, duration = "default" }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + expected_command + ) + ) + + test.wait_for_events() + test.mock_time.advance_time(3) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + ) + end +) + +-- Test central scene notifications +test.register_message_test( + "Central scene notification should emit button events", + { + { + channel = "zwave", + direction = "receive", + message = { mock_inovelli_vzw31_sn.id, zw_test_utils.zwave_test_build_receive_command(CentralScene:Notification({ + scene_number = 1, + key_attributes=CentralScene.key_attributes.KEY_PRESSED_1_TIME + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzw31_sn:generate_test_message("button1", capabilities.button.button.pushed({ + state_change = true + })) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +-- Test central scene notifications - button2 pressed 4 times +test.register_message_test( + "Central scene notification button2 pressed 4 times should emit button events", + { + { + channel = "zwave", + direction = "receive", + message = { mock_inovelli_vzw31_sn.id, zw_test_utils.zwave_test_build_receive_command(CentralScene:Notification({ + scene_number = 2, + key_attributes=CentralScene.key_attributes.KEY_PRESSED_4_TIMES + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_inovelli_vzw31_sn:generate_test_message("button2", capabilities.button.button.pushed_4x({ + state_change = true + })) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +-- Test refresh capability +test.register_message_test( + "Refresh capability should request switch level and meter data", + { + { + channel = "capability", + direction = "receive", + message = { + mock_inovelli_vzw31_sn.id, + { capability = "refresh", command = "refresh", args = {} } + } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + SwitchMultilevel:Get({}) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.WATTS }) + ) + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Meter:Get({ scale = Meter.scale.electric_meter.KILOWATT_HOURS }) + ) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_child.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_child.lua new file mode 100644 index 0000000000..dea43715ab --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_child.lua @@ -0,0 +1,335 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Configuration = (require "st.zwave.CommandClass.Configuration")({version=4}) +local t_utils = require "integration_test.utils" +local st_device = require "st.device" + +-- Inovelli VZW31-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_VZW31_SN_PRODUCT_ID = 0x0001 + +-- Device endpoints with supported command classes +local inovelli_vzw31_sn_endpoints = { + { + command_classes = { + {value = zw.SWITCH_BINARY}, + {value = zw.SWITCH_MULTILEVEL}, + {value = zw.BASIC}, + {value = zw.CONFIGURATION}, + {value = zw.CENTRAL_SCENE}, + {value = zw.ASSOCIATION}, + } + } +} + +-- Create mock parent device +local mock_parent_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-dimmer-vzw31-sn.yml"), + zwave_endpoints = inovelli_vzw31_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW31_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW31_SN_PRODUCT_ID +}) + +-- Create mock child device (notification device) +local mock_child_device = test.mock_device.build_test_child_device({ + profile = t_utils.get_profile_definition("rgbw-bulb.yml"), + parent_device_id = mock_parent_device.id, + parent_assigned_child_key = "notification" +}) + +-- Set child device network type +mock_child_device.network_type = st_device.NETWORK_TYPE_CHILD + +local function test_init() + test.mock_device.add_test_device(mock_parent_device) + test.mock_device.add_test_device(mock_child_device) +end +test.set_test_init_function(test_init) + +-- Test child device initialization +test.register_message_test( + "Child device should initialize with default color values", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_child_device.id, "added" }, + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.hue(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(1)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperatureRange({ value = {minimum = 2700, maximum = 6500} })) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switchLevel.level(100)) + }, + { + channel = "capability", + direction = "send", + message = mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + }, + }, + { + inner_block_ordering = "relaxed" + } +) + +-- Test child device switch on command (Gen3 uses parameter 99, same as vzw32) +test.register_coroutine_test( + "Child device switch on should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue (Gen3) + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = 100 -- Default color for child devices (since device starts with no hue state) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "on", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end +) + +-- Test child device switch off command +test.register_coroutine_test( + "Child device switch off should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switch", command = "off", args = {} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("off")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = 0, -- Switch off sends 0 + size = 4 + }) + ) + ) + end +) + +-- Test child device level command +test.register_coroutine_test( + "Child device level command should emit events and send configuration to parent", + function() + local level = math.random(1, 99) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue (Gen3) + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local effect = 1 -- Default notificationType + local color = 100 -- Default color for child devices (since device starts with no hue state) + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) -- Use the actual level from command + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "switchLevel", command = "setLevel", args = { level } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switchLevel.level(level)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end +) + +-- Test child device color command +test.register_coroutine_test( + "Child device color command should emit events and send configuration to parent", + function() + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + -- Calculate expected configuration value using the same logic as getNotificationValue (Gen3) + local function huePercentToValue(value) + if value <= 2 then + return 0 + elseif value >= 98 then + return 255 + else + return math.floor(value / 100 * 255 + 0.5) -- utils.round equivalent + end + end + + local notificationValue = 0 + local level = 100 -- Default level for child devices + local color = math.random(0, 100) + local effect = 1 -- Default notificationType + + notificationValue = notificationValue + (effect * 16777216) + notificationValue = notificationValue + (huePercentToValue(color) * 65536) + notificationValue = notificationValue + (level * 256) + notificationValue = notificationValue + (255 * 1) + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorControl", command = "setColor", args = {{ hue = color, saturation = 100 }} } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(color)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.saturation(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = notificationValue, + size = 4 + }) + ) + ) + end +) + +-- Test child device color temperature command +test.register_coroutine_test( + "Child device color temperature command should emit events and send configuration to parent", + function() + local temp = math.random(2700, 6500) + test.timer.__create_and_queue_test_time_advance_timer(1, "oneshot") + + test.socket.capability:__queue_receive({ + mock_child_device.id, + { capability = "colorTemperature", command = "setColorTemperature", args = { temp } } + }) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorControl.hue(100)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.colorTemperature.colorTemperature(temp)) + ) + + test.socket.capability:__expect_send( + mock_child_device:generate_test_message("main", capabilities.switch.switch("on")) + ) + + test.wait_for_events() + test.mock_time.advance_time(1) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_parent_device, + Configuration:Set({ + parameter_number = 99, + configuration_value = 33514751, -- Calculated: effect(1)*16777216 + hue(255)*65536 + level(100)*256 + 255 + size = 4 + }) + ) + ) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_preferences.lua b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_preferences.lua new file mode 100644 index 0000000000..2b4812ad7b --- /dev/null +++ b/drivers/SmartThings/zwave-switch/src/test/test_inovelli_vzw31_sn_preferences.lua @@ -0,0 +1,151 @@ +-- Copyright 2025 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +local test = require "integration_test" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local Configuration = (require "st.zwave.CommandClass.Configuration")({ version=4 }) +local t_utils = require "integration_test.utils" + +-- Inovelli VZW31-SN device identifiers +local INOVELLI_MANUFACTURER_ID = 0x031E +local INOVELLI_VZW31_SN_PRODUCT_TYPE = 0x0015 +local INOVELLI_VZW31_SN_PRODUCT_ID = 0x0001 + +-- Device endpoints with supported command classes +local inovelli_vzw31_sn_endpoints = { + { + command_classes = { + { value = zw.SWITCH_BINARY }, + { value = zw.SWITCH_MULTILEVEL }, + { value = zw.BASIC }, + { value = zw.CONFIGURATION }, + { value = zw.CENTRAL_SCENE }, + { value = zw.ASSOCIATION }, + } + } +} + +-- Create mock device +local mock_inovelli_vzw31_sn = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("inovelli-dimmer-vzw31-sn.yml"), + zwave_endpoints = inovelli_vzw31_sn_endpoints, + zwave_manufacturer_id = INOVELLI_MANUFACTURER_ID, + zwave_product_type = INOVELLI_VZW31_SN_PRODUCT_TYPE, + zwave_product_id = INOVELLI_VZW31_SN_PRODUCT_ID +}) + +local function test_init() + test.mock_device.add_test_device(mock_inovelli_vzw31_sn) +end +test.set_test_init_function(test_init) + +-- Test parameter 1 (example preference) +do + local new_param_value = 10 + test.register_coroutine_test( + "Parameter 1 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter1 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 1, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end + ) +end + +-- Test parameter 52 (example preference) +do + local new_param_value = 25 + test.register_coroutine_test( + "Parameter 52 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter52 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 52, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end + ) +end + +-- Test parameter 158 (example preference) +do + local new_param_value = 5 + test.register_coroutine_test( + "Parameter 158 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter158 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 158, + configuration_value = new_param_value, + size = 1 + }) + ) + ) + end + ) +end + +-- Test parameter 19 (2-byte parameter); must be non-default (default 3600) or driver won't send Configuration:Set +do + local new_param_value = 1800 + test.register_coroutine_test( + "Parameter 19 should be updated in the device configuration after change", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {parameter19 = new_param_value}})) + + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_inovelli_vzw31_sn, + Configuration:Set({ + parameter_number = 19, + configuration_value = new_param_value, + size = 2, + }) + ) + ) + end + ) +end + +-- Test notificationChild preference (special case for child device creation) +do + local new_param_value = true + test.register_coroutine_test( + "notificationChild preference should create child device when enabled", + function() + test.socket.device_lifecycle:__queue_receive(mock_inovelli_vzw31_sn:generate_info_changed({preferences = {notificationChild = new_param_value}})) + + -- Expect child device creation + mock_inovelli_vzw31_sn:expect_device_create({ + type = "EDGE_CHILD", + label = "nil Notification", -- This will be the parent label + "Notification" + profile = "rgbw-bulb", + parent_device_id = mock_inovelli_vzw31_sn.id, + parent_assigned_child_key = "notification" + }) + end + ) +end + +test.run_registered_tests()