diff --git a/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua b/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua index 05c9dbb7e9..f0b3229f24 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua @@ -93,34 +93,46 @@ local function _emit_light_events_inner(light_device, light_repr) light_device.log.debug(st_utils.stringify_table(color_temp_range, "color temp range unchanged")); end - -- local min = or Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE - local kelvin = math.floor( - st_utils.clamp_value(utils.mirek_to_kelvin(mirek), min_kelvin, max_kelvin) - ) - if utils.is_nan(kelvin) then - light_device.log.warn( - string.format( - "Non numeric value %s computed for colorTemperature Attribute Event, ignoring.", - kelvin - ) - ) + -- In xy colour mode the bridge reports `mirek_valid = false` (and `mirek = null`); there is + -- no colour temperature to report then. + if light_repr.color_temperature.mirek_valid == false then + light_device.log.debug("Light is in colour mode, not emitting a colour temperature") else - local last_kelvin_setpoint = light_device:get_field(Fields.COLOR_TEMP_SETPOINT) - if - last_kelvin_setpoint ~= nil and - last_kelvin_setpoint >= utils.mirek_to_kelvin(mirek + 1) and - last_kelvin_setpoint <= utils.mirek_to_kelvin(mirek - 1) - then - kelvin = last_kelvin_setpoint; + -- local min = or Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE + local kelvin = math.floor( + st_utils.clamp_value(utils.mirek_to_kelvin(mirek), min_kelvin, max_kelvin) + ) + if utils.is_nan(kelvin) then + light_device.log.warn( + string.format( + "Non numeric value %s computed for colorTemperature Attribute Event, ignoring.", + kelvin + ) + ) + else + local last_kelvin_setpoint = light_device:get_field(Fields.COLOR_TEMP_SETPOINT) + if + last_kelvin_setpoint ~= nil and + last_kelvin_setpoint >= utils.mirek_to_kelvin(mirek + 1) and + last_kelvin_setpoint <= utils.mirek_to_kelvin(mirek - 1) + then + kelvin = last_kelvin_setpoint; + end + light_device:emit_event(capabilities.colorTemperature.colorTemperature(kelvin)) end - light_device:emit_event(capabilities.colorTemperature.colorTemperature(kelvin)) end light_device:set_field(Fields.COLOR_TEMP_SETPOINT, nil); end if light_repr.color then - light_device:set_field(Fields.GAMUT, light_repr.color.gamut, { persist = true }) - local r, g, b = HueColorUtils.safe_xy_to_rgb(light_repr.color.xy, light_repr.color.gamut) + -- Updates (e.g. over SSE) usually carry only `xy`; keep the gamut learned from the full resource. + local gamut = light_repr.color.gamut + if gamut ~= nil then + light_device:set_field(Fields.GAMUT, gamut, { persist = true }) + else + gamut = light_device:get_field(Fields.GAMUT) + end + local r, g, b = HueColorUtils.safe_xy_to_rgb(light_repr.color.xy, gamut) local hue, sat, _ = st_utils.rgb_to_hsv(r, g, b) -- We sent a command where hue == 100 and wrapped the value to 0, reverse that here if light_device:get_field(Fields.WRAPPED_HUE) == true and (hue + .05 >= 1 or hue - .05 <= 0) then diff --git a/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua b/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua index 266971b99c..30b06cbcfc 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua @@ -65,15 +65,23 @@ local function do_switch_level_action(driver, bridge_device, group, args) return end - -- An individual command checks the state of the device before doing this. - -- It is probably not worth iterating through all the devices to check their state. - local resp, err = hue_api:set_grouped_light_on_state(grouped_light_id, true) - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing on/off action: " .. err) - elseif resp and #resp.errors > 0 then - for _, error in ipairs(resp.errors) do - log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) + -- As with an individual command, only turn the group on first if one of its lights is off. + local any_off = false + for _, device in ipairs(group.devices or {}) do + if device:get_field(Fields.SWITCH_STATE) == "off" then + any_off = true + break + end + end + if any_off then + local resp, err = hue_api:set_grouped_light_on_state(grouped_light_id, true) + if not resp or (resp.errors and #resp.errors == 0) then + if err ~= nil then + log.error_with({ hub_logs = true }, "Error performing on/off action: " .. err) + elseif resp and #resp.errors > 0 then + for _, error in ipairs(resp.errors) do + log.error_with({ hub_logs = true }, "Error returned in Hue response: " .. error.description) + end end end end diff --git a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua index edbfb02944..0e3f2ca848 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua @@ -227,8 +227,14 @@ function LightLifecycleHandlers.init(driver, device) if not svc_rids_for_device[device_light_resource_id] then svc_rids_for_device[device_light_resource_id] = HueDeviceTypes.LIGHT end + if hue_device_id ~= nil then + driver.services_for_device_rid[hue_device_id] = svc_rids_for_device + end device:set_field(Fields._INIT, true, { persist = false }) - device:emit_event(capabilities.switchLevel.levelRange({ minimum = 1, maximum = 100 })) + -- On/off-only lights (e.g. smart plugs) have no switchLevel capability. + if device:supports_capability_by_id(capabilities.switchLevel.ID) then + device:emit_event(capabilities.switchLevel.levelRange({ minimum = 1, maximum = 100 })) + end if device:get_field(Fields._REFRESH_AFTER_INIT) then driver:inject_capability_command(device, { diff --git a/drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua b/drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua index 7050f606f2..41fa225030 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/refresh_handlers.lua @@ -290,7 +290,7 @@ function RefreshHandlers.do_refresh_light(driver, light_device, light_status_cac if light_info ~= nil then if light_info.id == light_resource_id then if light_info.color ~= nil and light_info.color.gamut then - light_device:set_field(Fields.GAMUT, light_info.color.gamut_type, { persist = true }) + light_device:set_field(Fields.GAMUT, light_info.color.gamut, { persist = true }) end attribute_emitters.emit_light_attribute_events(light_device, light_info) do_light_request = false @@ -348,7 +348,7 @@ function RefreshHandlers.do_refresh_light(driver, light_device, light_status_cac for _, light_info in ipairs(rest_resp.data) do if light_info.id == light_resource_id then if light_info.color ~= nil and light_info.color.gamut then - light_device:set_field(Fields.GAMUT, light_info.color.gamut_type, { persist = true }) + light_device:set_field(Fields.GAMUT, light_info.color.gamut, { persist = true }) end attribute_emitters.emit_light_attribute_events(light_device, light_info) return light_info diff --git a/drivers/SmartThings/philips-hue/src/hue/api.lua b/drivers/SmartThings/philips-hue/src/hue/api.lua index 6039b395cc..7dc3593406 100644 --- a/drivers/SmartThings/philips-hue/src/hue/api.lua +++ b/drivers/SmartThings/philips-hue/src/hue/api.lua @@ -119,7 +119,11 @@ local function process_rest_response(response, err, partial, err_callback) ) end - return table.unpack(json_result, 1, json_result.n) + -- json.decode (dkjson) returns (value, next_position, error_message) -- only the first of + -- those is the decoded value this function documents returning; propagating all of them + -- here means the *parse position* gets misinterpreted as this function's `err` return by + -- every caller, on every successful decode. + return json_result[1] else return nil, "no response or error received" end diff --git a/drivers/SmartThings/philips-hue/src/test/fixtures/capabilities/samsungim.hueSyncMode.json b/drivers/SmartThings/philips-hue/src/test/fixtures/capabilities/samsungim.hueSyncMode.json new file mode 100644 index 0000000000..34c19badd9 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/fixtures/capabilities/samsungim.hueSyncMode.json @@ -0,0 +1,30 @@ +{ + "id": "samsungim.hueSyncMode", + "version": 1, + "status": "proposed", + "name": "Hue Sync Mode", + "ephemeral": false, + "attributes": { + "mode": { + "schema": { + "type": "object", + "properties": { + "value": { + "type": "string", + "title": "Hue Sync mode", + "enum": [ + "normal", + "streaming" + ] + } + }, + "additionalProperties": false, + "required": [ + "value" + ] + }, + "enumCommands": [] + } + }, + "commands": {} +} diff --git a/drivers/SmartThings/philips-hue/src/test/fixtures/capture_payloads.lua b/drivers/SmartThings/philips-hue/src/test/fixtures/capture_payloads.lua new file mode 100644 index 0000000000..9efecc4dbe --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/fixtures/capture_payloads.lua @@ -0,0 +1,82 @@ +--- Real Hue bridge payloads recorded by the instrumented driver during physical testing. +--- Identifiers (UUIDs, bridge ids, MACs, IPs) are replaced with stable fakes; everything +--- else is verbatim. Each payload is stored as its original JSON text and decoded on access +--- so tests always get a fresh table. +local json = require "st.json" + +local M = {} + +--- Fake ids for the resources tests refer to directly. +M.ids = { + color_lamp_device = "f0000000-0000-4000-8000-000000000001", + color_lamp_light = "f0000000-0000-4000-8000-000000000002", + color_lamp_zigbee = "f0000000-0000-4000-8000-000000000003", + color_lamp_2_light = "f0000000-0000-4000-8000-000000000004", + plug_device = "f0000000-0000-4000-8000-000000000005", + plug_light = "f0000000-0000-4000-8000-000000000006", + plug_zigbee = "f0000000-0000-4000-8000-000000000007", +} + +local raw = {} + +-- Bridge reported a new LCT014 colour lamp (device, zigbee_connectivity, light, entertainment, motion_area_candidate, device_software_update). +raw.color_lamp_added = [=[[{"creationtime":"2026-10-13T18:39:55Z","data":[{"id":"f0000000-0000-4000-8000-000000000001","id_v1":"/lights/6","identify":{},"metadata":{"archetype":"sultan_bulb","name":"Hue color lamp 1"},"product_data":{"certified":true,"manufacturer_name":"Signify Netherlands B.V.","model_id":"LCT014","product_archetype":"sultan_bulb","product_name":"Hue color lamp","software_version":"1.116.12"},"services":[{"rid":"f0000000-0000-4000-8000-000000000003","rtype":"zigbee_connectivity"},{"rid":"f0000000-0000-4000-8000-000000000002","rtype":"light"},{"rid":"f0000000-0000-4000-8000-000000000008","rtype":"entertainment"},{"rid":"f0000000-0000-4000-8000-000000000009","rtype":"motion_area_candidate"},{"rid":"f0000000-0000-4000-8000-000000000010","rtype":"device_software_update"}],"type":"device"},{"id":"f0000000-0000-4000-8000-000000000003","id_v1":"/lights/6","mac_address":"00:17:88:01:00:00:00:01","owner":{"rid":"f0000000-0000-4000-8000-000000000001","rtype":"device"},"status":"connected","type":"zigbee_connectivity"},{"alert":{"action_values":["breathe"]},"color":{"gamut":{"blue":{"x":0.1532,"y":0.0475},"green":{"x":0.17,"y":0.7},"red":{"x":0.6915,"y":0.3083}},"gamut_type":"C","xy":{"x":0.4572,"y":0.4099}},"color_temperature":{"mirek":366,"mirek_schema":{"mirek_maximum":500,"mirek_minimum":153},"mirek_valid":true},"color_temperature_delta":{},"dimming":{"brightness":100.0,"min_dim_level":1.0},"dimming_delta":{},"dynamics":{"speed":0.0,"speed_valid":false,"status":"none","status_values":["none","dynamic_palette"]},"effects":{"effect_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"],"status":"no_effect","status_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"]},"effects_v2":{"action":{"effect_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"]},"status":{"effect":"no_effect","effect_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"]}},"id":"f0000000-0000-4000-8000-000000000002","id_v1":"/lights/6","identify":{},"metadata":{"archetype":"sultan_bulb","function":"mixed","name":"Hue color lamp 1"},"mode":"normal","on":{"on":true},"owner":{"rid":"f0000000-0000-4000-8000-000000000001","rtype":"device"},"powerup":{"color":{"color_temperature":{"mirek":366},"mode":"color_temperature"},"configured":true,"dimming":{"dimming":{"brightness":100.0},"mode":"dimming"},"on":{"mode":"on","on":{"on":true}},"preset":"safety"},"product_data":{"function":"mixed"},"service_id":0,"signaling":{"signal_values":["no_signal","on_off","on_off_color","alternating"]},"type":"light"},{"equalizer":true,"id":"f0000000-0000-4000-8000-000000000008","id_v1":"/lights/6","owner":{"rid":"f0000000-0000-4000-8000-000000000001","rtype":"device"},"proxy":true,"renderer":true,"renderer_reference":{"rid":"f0000000-0000-4000-8000-000000000002","rtype":"light"},"segments":{"configurable":false,"max_segments":1,"segments":[{"length":1,"start":0}]},"type":"entertainment"},{"capabilities":["sensor","collector","sync"],"characteristics":[],"id":"f0000000-0000-4000-8000-000000000009","owner":{"rid":"f0000000-0000-4000-8000-000000000001","rtype":"device"},"type":"motion_area_candidate"},{"id":"f0000000-0000-4000-8000-000000000010","owner":{"rid":"f0000000-0000-4000-8000-000000000001","rtype":"device"},"problems":[],"state":"no_update","type":"device_software_update"}],"id":"f0000000-0000-4000-8000-000000000011","type":"add"}]]=] + +-- GET light resource for the new colour lamp. +raw.color_lamp_light = [=[{"errors":[],"data":[{"id":"f0000000-0000-4000-8000-000000000002","id_v1":"/lights/6","owner":{"rid":"f0000000-0000-4000-8000-000000000001","rtype":"device"},"metadata":{"name":"Hue color lamp 1","archetype":"sultan_bulb","function":"mixed"},"product_data":{"function":"mixed"},"identify":{},"service_id":0,"on":{"on":true},"dimming":{"brightness":100.0,"min_dim_level":1.0},"dimming_delta":{},"color_temperature":{"mirek":366,"mirek_valid":true,"mirek_schema":{"mirek_minimum":153,"mirek_maximum":500}},"color_temperature_delta":{},"color":{"xy":{"x":0.4572,"y":0.4099},"gamut":{"red":{"x":0.6915,"y":0.3083},"green":{"x":0.17,"y":0.7},"blue":{"x":0.1532,"y":0.0475}},"gamut_type":"C"},"dynamics":{"status":"none","status_values":["none","dynamic_palette"],"speed":0.0,"speed_valid":false},"alert":{"action_values":["breathe"]},"signaling":{"signal_values":["no_signal","on_off","on_off_color","alternating"]},"mode":"normal","effects":{"status_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"],"status":"no_effect","effect_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"]},"effects_v2":{"action":{"effect_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"]},"status":{"effect":"no_effect","effect_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"]}},"powerup":{"preset":"safety","configured":true,"on":{"mode":"on","on":{"on":true}},"dimming":{"mode":"dimming","dimming":{"brightness":100.0}},"color":{"mode":"color_temperature","color_temperature":{"mirek":366}}},"type":"light"}]}]=] + +-- GET device resource for the new colour lamp. +raw.color_lamp_device = [=[{"errors":[],"data":[{"id":"f0000000-0000-4000-8000-000000000001","id_v1":"/lights/6","product_data":{"model_id":"LCT014","manufacturer_name":"Signify Netherlands B.V.","product_name":"Hue color lamp","product_archetype":"sultan_bulb","certified":true,"software_version":"1.116.12"},"metadata":{"name":"Hue color lamp 1","archetype":"sultan_bulb"},"identify":{},"services":[{"rid":"f0000000-0000-4000-8000-000000000003","rtype":"zigbee_connectivity"},{"rid":"f0000000-0000-4000-8000-000000000002","rtype":"light"},{"rid":"f0000000-0000-4000-8000-000000000008","rtype":"entertainment"},{"rid":"f0000000-0000-4000-8000-000000000009","rtype":"motion_area_candidate"},{"rid":"f0000000-0000-4000-8000-000000000010","rtype":"device_software_update"}],"type":"device"}]}]=] + +-- GET zigbee_connectivity for the new colour lamp. +raw.color_lamp_zigbee = [=[{"errors":[],"data":[{"id":"f0000000-0000-4000-8000-000000000003","id_v1":"/lights/6","owner":{"rid":"f0000000-0000-4000-8000-000000000001","rtype":"device"},"status":"connected","mac_address":"00:17:88:01:00:00:00:01","type":"zigbee_connectivity"}]}]=] + +-- Colour lamp lost its Zigbee connection. +raw.color_lamp_connectivity_issue = [=[[{"creationtime":"2026-10-13T19:12:01Z","data":[{"id":"f0000000-0000-4000-8000-000000000003","id_v1":"/lights/6","owner":{"rid":"f0000000-0000-4000-8000-000000000001","rtype":"device"},"status":"connectivity_issue","type":"zigbee_connectivity"}],"id":"f0000000-0000-4000-8000-000000000012","type":"update"}]]=] + +-- Colour lamp regained its Zigbee connection. +raw.color_lamp_connected = [=[[{"creationtime":"2026-10-13T19:13:39Z","data":[{"id":"f0000000-0000-4000-8000-000000000003","id_v1":"/lights/6","owner":{"rid":"f0000000-0000-4000-8000-000000000001","rtype":"device"},"status":"connected","type":"zigbee_connectivity"}],"id":"f0000000-0000-4000-8000-000000000013","type":"update"}]]=] + +-- setColor result: xy update with mirek null / mirek_valid false. +raw.color_lamp_2_color_mode = [=[[{"creationtime":"2026-10-13T17:28:24Z","data":[{"color":{"xy":{"x":0.6915,"y":0.3083}},"color_temperature":{"mirek":null,"mirek_valid":false},"id":"f0000000-0000-4000-8000-000000000004","id_v1":"/lights/1","owner":{"rid":"f0000000-0000-4000-8000-000000000014","rtype":"device"},"service_id":0,"type":"light"}],"id":"f0000000-0000-4000-8000-000000000015","type":"update"}]]=] + +-- GET light resource for the second colour lamp. +raw.color_lamp_2_light = [=[{"errors":[],"data":[{"id":"f0000000-0000-4000-8000-000000000004","id_v1":"/lights/1","owner":{"rid":"f0000000-0000-4000-8000-000000000014","rtype":"device"},"metadata":{"name":"Hue color lamp 1","archetype":"sultan_bulb","function":"mixed"},"product_data":{"function":"mixed"},"identify":{},"service_id":0,"on":{"on":true},"dimming":{"brightness":100.0,"min_dim_level":1.0},"dimming_delta":{},"color_temperature":{"mirek":369,"mirek_valid":true,"mirek_schema":{"mirek_minimum":153,"mirek_maximum":500}},"color_temperature_delta":{},"color":{"xy":{"x":0.459,"y":0.4103},"gamut":{"red":{"x":0.6915,"y":0.3083},"green":{"x":0.17,"y":0.7},"blue":{"x":0.1532,"y":0.0475}},"gamut_type":"C"},"dynamics":{"status":"none","status_values":["none","dynamic_palette"],"speed":0.0,"speed_valid":false},"alert":{"action_values":["breathe"]},"signaling":{"signal_values":["no_signal","on_off","on_off_color","alternating"]},"mode":"normal","effects":{"status_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"],"status":"no_effect","effect_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"]},"effects_v2":{"action":{"effect_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"]},"status":{"effect":"no_effect","effect_values":["no_effect","candle","fire","prism","sparkle","opal","glisten"]}},"powerup":{"preset":"safety","configured":true,"on":{"mode":"on","on":{"on":true}},"dimming":{"mode":"dimming","dimming":{"brightness":100.0}},"color":{"mode":"color_temperature","color_temperature":{"mirek":366}}},"type":"light"}]}]=] + +-- Smart plug removed from the bridge (delete + resulting room/bridge_home updates). +raw.plug_deleted = [=[[{"creationtime":"2026-10-13T18:42:26Z","data":[{"id":"f0000000-0000-4000-8000-000000000007","id_v1":"/lights/4","type":"zigbee_connectivity"},{"id":"f0000000-0000-4000-8000-000000000006","id_v1":"/lights/4","type":"light"}],"id":"f0000000-0000-4000-8000-000000000016","type":"delete"},{"creationtime":"2026-10-13T18:42:26Z","data":[{"id":"f0000000-0000-4000-8000-000000000017","type":"device_software_update"}],"id":"f0000000-0000-4000-8000-000000000018","type":"delete"},{"creationtime":"2026-10-13T18:42:26Z","data":[{"id":"f0000000-0000-4000-8000-000000000005","id_v1":"/lights/4","services":[],"type":"device"}],"id":"f0000000-0000-4000-8000-000000000019","type":"update"},{"creationtime":"2026-10-13T18:42:26Z","data":[{"id":"f0000000-0000-4000-8000-000000000005","id_v1":"/lights/4","type":"device"}],"id":"f0000000-0000-4000-8000-000000000020","type":"delete"},{"creationtime":"2026-10-13T18:42:26Z","data":[{"actions":[{"action":{"color_temperature":{"mirek":370},"dimming":{"brightness":100.0},"on":{"on":true}},"target":{"rid":"f0000000-0000-4000-8000-000000000021","rtype":"light"}},{"action":{"dimming":{"brightness":100.0},"on":{"on":true}},"target":{"rid":"f0000000-0000-4000-8000-000000000022","rtype":"light"}},{"action":{"color_temperature":{"mirek":370},"dimming":{"brightness":100.0},"on":{"on":true}},"target":{"rid":"f0000000-0000-4000-8000-000000000002","rtype":"light"}}],"id":"f0000000-0000-4000-8000-000000000023","id_v1":"/scenes/jq7k9Hbft1qgrcy9","type":"scene"}],"id":"f0000000-0000-4000-8000-000000000024","type":"update"},{"creationtime":"2026-10-13T18:42:26Z","data":[{"children":[{"rid":"f0000000-0000-4000-8000-000000000001","rtype":"device"},{"rid":"f0000000-0000-4000-8000-000000000025","rtype":"device"},{"rid":"f0000000-0000-4000-8000-000000000026","rtype":"device"},{"rid":"f0000000-0000-4000-8000-000000000027","rtype":"device"},{"rid":"f0000000-0000-4000-8000-000000000028","rtype":"device"}],"id":"f0000000-0000-4000-8000-000000000029","id_v1":"/groups/81","type":"room"}],"id":"f0000000-0000-4000-8000-000000000030","type":"update"}]]=] + +-- GET light resource for the on/off smart plug (no dimming). +raw.plug_light = [=[{"errors":[],"data":[{"id":"f0000000-0000-4000-8000-000000000006","id_v1":"/lights/4","owner":{"rid":"f0000000-0000-4000-8000-000000000005","rtype":"device"},"metadata":{"name":"Hue smart plug 1","archetype":"plug","function":"functional"},"product_data":{"function":"functional"},"identify":{},"service_id":0,"on":{"on":true},"dynamics":{"status":"none","status_values":["none"],"speed":0.0,"speed_valid":false},"alert":{"action_values":[]},"mode":"normal","powerup":{"preset":"powerfail","configured":true,"on":{"mode":"previous"}},"type":"light"}]}]=] + +-- GET device resource for the smart plug. +raw.plug_device = [=[{"errors":[],"data":[{"id":"f0000000-0000-4000-8000-000000000005","id_v1":"/lights/4","product_data":{"model_id":"LOM010","manufacturer_name":"Signify Netherlands B.V.","product_name":"Hue smart plug","product_archetype":"plug","certified":true,"software_version":"1.163.1","hardware_platform_type":"100b-11a"},"metadata":{"name":"Hue smart plug 1","archetype":"plug"},"identify":{},"services":[{"rid":"f0000000-0000-4000-8000-000000000007","rtype":"zigbee_connectivity"},{"rid":"f0000000-0000-4000-8000-000000000006","rtype":"light"},{"rid":"f0000000-0000-4000-8000-000000000017","rtype":"device_software_update"}],"type":"device"}]}]=] + +-- GET /api/config for the bridge that was paired with the hub. +raw.bridge_config = [=[{"name":"Hue Bridge","datastoreversion":"197","swversion":"1978418030","apiversion":"1.78.0","mac":"00:17:88:00:00:01","bridgeid":"001788FFFE000001","factorynew":false,"replacesbridgeid":null,"modelid":"BSB002","starterkitid":""}]=] + +-- GET /api/config for a second bridge on the same network that was never paired. +raw.unpaired_bridge_config = [=[{"name":"Hue Bridge_regre","datastoreversion":"197","swversion":"1978418030","apiversion":"1.78.0","mac":"00:17:88:00:00:02","bridgeid":"001788FFFE000002","factorynew":false,"replacesbridgeid":null,"modelid":"BSB002","starterkitid":""}]=] + +-- Unrelated resource update. +raw.noise_homekit = [=[[{"creationtime":"2026-10-13T18:26:45Z","data":[{"id":"f0000000-0000-4000-8000-000000000031","status":"pairing","type":"homekit"}],"id":"f0000000-0000-4000-8000-000000000032","type":"update"}]]=] + +-- Unrelated resource adds/updates after reconnect. +raw.noise_discovery_geolocation = [=[[{"creationtime":"2026-10-13T18:26:46Z","data":[{"id":"f0000000-0000-4000-8000-000000000033","owner":{"rid":"f0000000-0000-4000-8000-000000000034","rtype":"device"},"status":"ready","type":"zigbee_device_discovery"}],"id":"f0000000-0000-4000-8000-000000000035","type":"update"},{"creationtime":"2026-10-13T18:26:49Z","data":[{"id":"f0000000-0000-4000-8000-000000000036","is_configured":false,"type":"geolocation"}],"id":"f0000000-0000-4000-8000-000000000037","type":"add"},{"creationtime":"2026-10-13T18:26:50Z","data":[{"id":"f0000000-0000-4000-8000-000000000038","mac_address":"00:17:88:01:00:00:00:02","owner":{"rid":"f0000000-0000-4000-8000-000000000034","rtype":"device"},"status":"connected","type":"zigbee_connectivity"},{"channel":{"value":"channel_25"},"id":"f0000000-0000-4000-8000-000000000038","owner":{"rid":"f0000000-0000-4000-8000-000000000034","rtype":"device"},"type":"zigbee_connectivity"},{"extended_pan_id":"0000000000000001","id":"f0000000-0000-4000-8000-000000000038","owner":{"rid":"f0000000-0000-4000-8000-000000000034","rtype":"device"},"type":"zigbee_connectivity"}],"id":"f0000000-0000-4000-8000-000000000039","type":"update"}]]=] + +-- Unrelated resource add. +raw.noise_behavior_script = [=[[{"creationtime":"2026-10-13T18:26:53Z","data":[{"configuration_schema":{"$ref":"goto_sleep_config.json#"},"description":"Get ready for nice sleep.","id":"f0000000-0000-4000-8000-000000000040","metadata":{"category":"automation","name":"Go to sleep routines"},"state_schema":{"$ref":"goto_sleep_state.json#"},"supported_features":["style_sunset_gts"],"trigger_schema":{"$ref":"trigger.json#"},"type":"behavior_script","version":"0.0.1"}],"id":"f0000000-0000-4000-8000-000000000041","type":"add"}]]=] + +-- Unrelated resource update. +raw.noise_behavior_instance = [=[[{"creationtime":"2026-10-13T18:26:55Z","data":[{"id":"f0000000-0000-4000-8000-000000000042","status":"initializing","type":"behavior_instance"}],"id":"f0000000-0000-4000-8000-000000000043","type":"update"}]]=] + +--- Decode a payload by name. +--- @param name string +--- @return table +function M.get(name) + local text = raw[name] + assert(text, "no capture payload named " .. tostring(name)) + return (json.decode(text)) +end + +return M diff --git a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua new file mode 100644 index 0000000000..06b41f3913 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -0,0 +1,1710 @@ +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local lan_test_utils = require "integration_test.lan_test_utils" +local capabilities = require "st.capabilities" + +local Fields = require "fields" +local HueApi = require "hue.api" + +--- Shared fixture helpers for building a "known, already paired" Hue bridge + child device(s) +--- for integration tests. +--- +--- The real `added`/`init` lifecycle handlers do substantial discovery/pairing work (scanning +--- for the bridge on the network, waiting for the Link Button, querying the bridge for each +--- light's initial state, ...) that runs for real against the mock LAN socket every time a +--- test device goes through lifecycle. These helpers pre-populate every cache/datastore field +--- that work depends on (`driver.datastore.bridge_netinfo`/`.api_keys`, `disco`'s +--- `device_state_disco_cache`, per-device fields) so that added/init resolve synchronously to +--- a steady, already-paired state instead of falling into their discovery/long-poll paths -- +--- those paths are covered separately in test_hue_bridge_discovery.lua. +--- +--- ## RECOMMENDED USAGE +--- +--- Use HueDeviceBuilder to create test fixtures with ConnectionScenario 2.0 for REST +--- expectations and SSE event handling. See the documentation sections below for examples. +-- `samsungim.hueSyncMode` is defined by the platform, not the driver package; its definition +-- (fetched from the SmartThings API) lets the driver emit Hue sync mode events in tests. +test.add_capability_definition("fixtures/capabilities/samsungim.hueSyncMode.json") + +local M = {} + +M.BRIDGE_IP = "192.168.1.15" +M.BRIDGE_DNI = "AA:BB:CC:DD:EE:FF" +M.API_KEY = "test-api-key" + + +--- `LightLifecycleHandlers.init` unconditionally emits a levelRange event on every light's +--- first init, regardless of bridge/pairing state. HueDeviceBuilder's `test_init` already +--- calls this; only call it directly if building a fixture by hand. +--- +--- Must be called from `test_init` (synchronous setup, before `require "init"` runs) rather +--- than from within a coroutine test body for proper event expectation timing. +--- +--- @param mock_light table +function M.expect_light_init_events(mock_light) + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switchLevel.levelRange({ minimum = 1, maximum = 100 })) + ) +end + +--- Refresh (and other per-device flows) check that the bridge has finished initializing via +--- Fields._INIT, which is normally set inside do_bridge_network_init once bridge setup fully +--- completes -- the same step that creates the bridge's SSE EventSource. Since SSE connects to +--- the same host:port as REST calls, and the mock LAN socket models one connection per address, +--- letting do_bridge_network_init run for real would interleave the SSE connection's own bytes +--- into REST-focused assertions. Call this from within a test body (after the automatic +--- added/init lifecycle burst has already run -- i.e. as the first statement in the test, not +--- from test_init) to mark the bridge initialized directly instead. +--- +--- @param mock_bridge table +function M.mark_bridge_initialized(mock_bridge) + mock_bridge:set_field(Fields._INIT, true, {}) +end + + +--- ## Test Pattern with ConnectionScenario 2.0 +--- +--- Use HueDeviceBuilder to create test fixtures and ConnectionScenario 2.0 helpers +--- for REST expectations and SSE event building. +--- +--- ### Example: Button Device with SSE +--- +--- ```lua +--- local fixtures = hue_test_helpers.HueDeviceBuilder.new() +--- :with_bridge() +--- :with_button("button-rid", { num_buttons = 1, battery = 85 }) +--- :enable_sse() +--- :start() +--- +--- local mock_bridge, mock_button = fixtures.bridge, fixtures.devices[1] +--- +--- -- Setup ConnectionScenario 2.0 +--- local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +--- local rest, sse = conns.rest, conns.sse +--- +--- -- Setup expectations using device-specific helper +--- hue_test_helpers.setup_button_init_expectations(rest, sse, fixtures.configs.button[1]) +--- +--- -- Activate scenario +--- hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) +--- +--- -- Send SSE events in tests: +--- http.queue_sse_event(sse, { hue_test_helpers.button_event(button_rid, "short_release") }) +--- ``` +--- +--- ### HueDeviceBuilder Methods: +--- +--- - `with_bridge(ip, dni, api_key)` - Configure bridge (all optional) +--- - `with_light(rid, state, profile)` - Add light device +--- - `with_button(rid, config, profile)` - Add button device +--- - `with_motion(rid, config)` - Add motion sensor +--- - `with_contact(rid, config)` - Add contact sensor +--- - `enable_sse()` - Enable SSE support +--- - `start()` - Build fixtures and return structured object: +--- - `fixtures.bridge` - Mock bridge device +--- - `fixtures.devices` - Array of mock child devices +--- - `fixtures.test_init` - Function to pass to test.set_test_init_function +--- - `fixtures.configs` - Device configs by type (button, light, motion, contact) +--- +--- ### Helper Functions: +--- +--- **Connection Setup:** +--- - `create_hue_scenario(options)` - Create ConnectionScenario with REST/SSE +--- - `setup_scenario_test_init(base, scenario, additional)` - Setup test init +--- +--- **Device-Type Init Expectations (High-Level):** +--- - `setup_button_init_expectations(rest, sse, button_config, options)` - All button init expectations +--- - `setup_light_init_expectations(rest, light_config, options)` - All light init expectations +--- - `setup_motion_init_expectations(rest, sse, motion_config, options)` - All motion sensor init expectations +--- - `setup_contact_init_expectations(rest, sse, contact_config, options)` - All contact sensor init expectations +--- +--- **Low-Level REST Expectations:** +--- - `expect_device_info()`, `expect_zigbee_connectivity()`, `expect_device_power()` +--- - `expect_button_resource()`, `expect_motion_resource()`, `expect_light_resource()` +--- - `setup_sse_expectations()` - SSE handshake +--- +--- **SSE Event Builders:** +--- - `button_event()`, `motion_event()`, `light_event()` +--- - `contact_event()`, `temperature_event()`, `light_level_event()` + + +--- @class HueDeviceBuilder +--- Fluent API for building Hue test fixtures with sensible defaults. +--- Provides a clean, declarative way to set up bridge + child devices for tests. +local HueDeviceBuilder = {} +HueDeviceBuilder.__index = HueDeviceBuilder + +--- Create a new HueDeviceBuilder instance. +--- +--- @return HueDeviceBuilder +function M.HueDeviceBuilder_new() + local instance = { + bridge_ip = M.BRIDGE_IP, + bridge_dni = M.BRIDGE_DNI, + api_key = M.API_KEY, + sse_enabled = false, -- Use different name to avoid shadowing enable_sse() method + children = {}, -- Array of child device configs + } + return setmetatable(instance, HueDeviceBuilder) +end + +--- Configure the bridge (optional - uses sensible defaults). +--- +--- @param ip string|nil bridge IP (default: hue_test_helpers.BRIDGE_IP) +--- @param dni string|nil device network ID (default: hue_test_helpers.BRIDGE_DNI) +--- @param key string|nil API key (default: hue_test_helpers.API_KEY) +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:with_bridge(ip, dni, key) + if ip then self.bridge_ip = ip end + if dni then self.bridge_dni = dni end + if key then self.api_key = key end + return self +end + +--- Add a light device to the fixture. +--- +--- @param rid string Hue resource ID for the light +--- @param state table|nil initial state with fields matching Hue API format: +--- - on: table with 'on' field (default: {on=true}) +--- - dimming: table with 'brightness' field (default: {brightness=100}) +--- - color: table with xy and gamut (optional) +--- - color_temperature: table with mirek and schema (optional) +--- - mode: string (default: "normal") +--- - hue_device_id: string (default: rid.."-device") +--- - label: string (default: "Hue Light") +--- @param profile string|nil profile filename (default: "white-and-color-ambiance.yml") +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:with_light(rid, state, profile) + state = state or {} + local device_id = state.hue_device_id or (rid .. "-device") + + -- Build discovery cache state for the light + local disco_state = { + hue_provided_name = state.label or "Hue Light", + id = rid, + on = state.on or { on = true }, + color = state.color, + dimming = state.dimming or { brightness = 100 }, + color_temperature = state.color_temperature, + mode = state.mode or "normal", + hue_device_id = device_id, + hue_device_data = { + product_data = { + manufacturer_name = "Signify Netherlands B.V.", + model_id = "TEST", + product_name = state.label or "Hue Light", + }, + }, + } + + table.insert(self.children, { + type = "light", + rid = rid, + profile = profile or "white-and-color-ambiance.yml", + state = disco_state, + init_expectations = function(mock_device) + -- Lights always emit levelRange on init + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.switchLevel.levelRange({ minimum = 1, maximum = 100 }) + ) + ) + end + }) + return self +end + +--- Add a button device to the fixture. +--- +--- @param rid string Hue resource ID for the first button +--- @param config table configuration with: +--- - num_buttons: number of buttons (default 1) +--- - battery: battery level (default 85) +--- - label: device label (default "Hue Button") +--- - device_id: device ID (default: rid .. "-device") +--- - power_rid: power resource ID (default: rid .. "-power") +--- - button_rids: array of button RIDs (default: {rid, ...}) +--- @param profile string|nil profile filename (auto-selected based on num_buttons) +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:with_button(rid, config, profile) + config = config or {} + local num_buttons = config.num_buttons or 1 + local device_id = config.device_id or (rid .. "-device") + local power_rid = config.power_rid or (rid .. "-power") + + -- Build button RIDs array + local button_rids = config.button_rids or {rid} + if #button_rids < num_buttons then + for i = #button_rids + 1, num_buttons do + table.insert(button_rids, rid .. "-button" .. i) + end + end + + -- Auto-select profile based on number of buttons + if not profile then + if num_buttons == 1 then + profile = "single-button.yml" + elseif num_buttons == 4 then + profile = "4-button-remote.yml" + else + profile = "single-button.yml" -- fallback + end + end + + -- Build state table + local state = { + id = rid, + hue_provided_name = config.label or "Hue Button", + hue_device_id = device_id, + num_buttons = num_buttons, + power_state = { battery_level = config.battery or 85 }, + power_id = power_rid, + } + + -- Add button-specific fields + for i = 1, num_buttons do + state["button" .. i] = { + event_values = config.event_values or { "short_release", "long_press", "long_release" } + } + state["button" .. i .. "_id"] = button_rids[i] + end + + table.insert(self.children, { + type = "button", + rid = rid, + profile = profile, + state = state, + num_buttons = num_buttons, + battery = config.battery or 85, -- Store for later use in init_expectations + -- init_expectations will be created in start() based on sse_enabled + }) + return self +end + +--- Add a motion sensor to the fixture. +--- +--- @param rid string Hue resource ID for the motion sensor +--- @param config table|nil configuration with: +--- - battery: battery level (default 85) +--- - motion: initial motion state (default false) +--- - temperature: temperature in Celsius (default 20.0) +--- - light_level: light level (default 30000) +--- - label: device label (default "Hue Motion Sensor") +--- - device_id: device ID (default: rid .. "-device") +--- - power_rid: power resource ID (default: rid .. "-power") +--- - temperature_rid: temperature resource ID (default: rid .. "-temp") +--- - light_level_rid: light level resource ID (default: rid .. "-light") +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:with_motion(rid, config) + config = config or {} + local device_id = config.device_id or (rid .. "-device") + local power_rid = config.power_rid or (rid .. "-power") + local temperature_rid = config.temperature_rid or (rid .. "-temp") + local light_level_rid = config.light_level_rid or (rid .. "-light") + + -- Build discovery cache state for the motion sensor + local state = { + id = rid, + hue_provided_name = config.label or "Hue Motion Sensor", + hue_device_id = device_id, + motion = { motion = config.motion or false, motion_valid = true }, + motion_enabled = true, + temperature = { temperature = config.temperature or 20.0, temperature_valid = true }, + temperature_id = temperature_rid, + temperature_enabled = true, + light = { light_level = config.light_level or 30000, light_level_valid = true }, + light_level_id = light_level_rid, + light_level_enabled = true, + power_state = { battery_level = config.battery or 85 }, + power_id = power_rid, + sensor_list = { + id = "motion", + power_id = "device_power", + temperature_id = "temperature", + light_level_id = "light_level" + } + } + + table.insert(self.children, { + type = "motion", + rid = rid, + profile = "motion-sensor.yml", + state = state, + battery = config.battery or 85, + motion = config.motion or false, + temperature = config.temperature or 20.0, + light_level = config.light_level or 30000, + -- init_expectations will be created in start() based on sse_enabled + }) + return self +end + +--- Add a contact sensor to the fixture. +--- +--- @param rid string Hue resource ID for the contact sensor +--- @param config table|nil configuration with: +--- - battery: battery level (default 85) +--- - contact_state: initial contact state "contact"=closed, "no_contact"=open (default "contact") +--- - tamper: tamper state (default "not_tampered") +--- - label: device label (default "Hue Contact Sensor") +--- - device_id: device ID (default: rid .. "-device") +--- - power_rid: power resource ID (default: rid .. "-power") +--- - tamper_rid: tamper resource ID (default: rid .. "-tamper") +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:with_contact(rid, config) + config = config or {} + local device_id = config.device_id or (rid .. "-device") + local power_rid = config.power_rid or (rid .. "-power") + local tamper_rid = config.tamper_rid or (rid .. "-tamper") + + -- Build discovery cache state for the contact sensor + local state = { + id = rid, + hue_provided_name = config.label or "Hue Contact Sensor", + hue_device_id = device_id, + contact_report = { state = config.contact_state or "contact" }, -- "contact" = closed, "no_contact" = open + contact_enabled = true, + tamper_reports = { { state = config.tamper or "not_tampered" } }, + tamper_id = tamper_rid, + power_state = { battery_level = config.battery or 85 }, + power_id = power_rid, + sensor_list = { + id = "contact", + power_id = "device_power", + tamper_id = "tamper" + } + } + + table.insert(self.children, { + type = "contact", + rid = rid, + profile = "contact-sensor.yml", + state = state, + battery = config.battery or 85, + contact_state = config.contact_state or "contact", + tamper = config.tamper or "not_tampered", + -- init_expectations will be created in start() based on sse_enabled + }) + return self +end + +--- Add a Hue room containing some of this fixture's lights. +--- +--- Rooms are not discovered by the driver in tests (group scanning is disabled), so they are +--- applied after init by calling `fixtures.seed_groups()` from within a test, which feeds Hue +--- room resources through the driver's own `grouped_utils.group_add`. +--- +--- @param rid string Hue resource ID for the room +--- @param grouped_light_rid string resource ID of the room's grouped_light service +--- @param light_rids string[] resource IDs of lights (added with `with_light`) in the room +--- @param name string|nil room name (default "Test Room") +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:with_room(rid, grouped_light_rid, light_rids, name) + self.rooms = self.rooms or {} + table.insert(self.rooms, { + rid = rid, + grouped_light_rid = grouped_light_rid, + light_rids = light_rids, + name = name or "Test Room", + }) + return self +end + +--- Enable SSE support for this fixture. +--- +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:enable_sse() + self.sse_enabled = true + return self +end + +--- Whether a profile file declares a capability on any component. +local function profile_has_capability(profile_filename, capability_id) + for _, component in pairs(t_utils.get_profile_definition(profile_filename).components or {}) do + for _, capability in pairs(component.capabilities or {}) do + if capability.id == capability_id then return true end + end + end + return false +end + +--- Build the fixture and return a structured fixtures object. +--- This creates all mock devices and returns them with their configurations. +--- +--- @return table fixtures with fields: +--- - bridge: mock bridge device +--- - devices: array of mock child devices +--- - test_init: function to pass to test.set_test_init_function +--- - configs: table of device configs by type (button, light, motion, contact) +function HueDeviceBuilder:start() + local mock_bridge = test.mock_device.build_test_lan_device({ + label = "Hue Bridge", + profile = t_utils.get_profile_definition("hue-bridge.yml"), + device_network_id = self.bridge_dni, + }) + + local mock_children = {} + for _, child_config in ipairs(self.children) do + local child_template = { + label = child_config.state.hue_provided_name, + profile = t_utils.get_profile_definition(child_config.profile), + parent_assigned_child_key = child_config.type .. ":" .. child_config.rid, + parent_device_id = mock_bridge.id, + } + table.insert(mock_children, test.mock_device.build_test_lan_device(child_template)) + end + + local driver_under_test = nil + test.add_test_env_setup_func(function(driver) + driver_under_test = driver + driver.datastore.bridge_netinfo = driver.datastore.bridge_netinfo or {} + if self.sse_enabled then + driver.datastore.bridge_netinfo[self.bridge_dni] = { + ip = self.bridge_ip, + swversion = tostring(HueApi.MIN_CLIP_V2_SWVERSION), + modelid = "BSB002" + } + driver.joined_bridges[self.bridge_dni] = true + else + driver.datastore.bridge_netinfo[self.bridge_dni] = { + ip = self.bridge_ip, + swversion = "0", + modelid = "BSB002" + } + end + driver.datastore.api_keys = driver.datastore.api_keys or {} + driver.datastore.api_keys[self.bridge_dni] = self.api_key + + local disco = require "disco" + disco.disco_api_instances = {} + disco.discovery_active = self.sse_enabled or false + local grouped_utils = require "utils.grouped_utils" + grouped_utils.scanning_enabled = false + + -- Populate disco cache with child device states + for i, child_config in ipairs(self.children) do + child_config.state.parent_device_id = mock_bridge.id + disco.device_state_disco_cache[child_config.rid] = child_config.state + end + end) + + local function test_init() + test.set_test_coroutine_priority(true) + + test.mock_device.add_test_device(mock_bridge) + for _, mock_child in ipairs(mock_children) do + test.mock_device.add_test_device(mock_child) + end + + mock_bridge:set_field(Fields.DEVICE_TYPE, "bridge", {}) + mock_bridge:set_field(Fields.BRIDGE_ID, self.bridge_dni, {}) + mock_bridge:set_field(Fields.IPV4, self.bridge_ip, {}) + mock_bridge:set_field(HueApi.APPLICATION_KEY_HEADER, self.api_key, {}) + + -- Check if we have any non-light children (buttons, sensors, etc.) + -- These need the bridge marked as _ADDED to avoid being treated as stray devices + local has_non_light_children = false + for _, child_config in ipairs(self.children) do + if child_config.type ~= "light" then + has_non_light_children = true + break + end + end + + if has_non_light_children then + mock_bridge:set_field(Fields._ADDED, true, { persist = true }) + -- Don't mark _INIT yet if SSE is enabled - let do_bridge_network_init run to set up SSE + if not self.sse_enabled then + mock_bridge:set_field(Fields._INIT, true, { persist = true }) + end + end + + -- Register init expectations for all children + -- Generate init_expectations based on device type and SSE status + for i, child_config in ipairs(self.children) do + local mock_child = mock_children[i] + + if child_config.type == "button" then + -- Button devices emit supportedButtonValues for each component + local components = {"main"} + for j = 2, child_config.num_buttons do + table.insert(components, "button" .. j) + end + + for _, component in ipairs(components) do + test.socket.capability:__expect_send( + mock_child:generate_test_message(component, + capabilities.button.supportedButtonValues( + { "pushed", "held" }, + { visibility = { displayed = false } } + ) + ) + ) + end + + -- Battery event from refresh during init (only if SSE is enabled) + if self.sse_enabled then + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.battery.battery(child_config.battery) + ) + ) + end + + elseif child_config.type == "motion" then + -- Motion sensors emit battery event from refresh during init (only if SSE enabled) + if self.sse_enabled then + test.socket.capability:__set_channel_ordering("relaxed") + + -- Motion state + local motion_value = child_config.motion and "active" or "inactive" + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.motionSensor.motion[motion_value]() + ) + ) + + -- Temperature + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.temperatureMeasurement.temperature({ + value = child_config.temperature, + unit = "C" + }) + ) + ) + + -- Illuminance (convert light_level to lux: lux = round(10^((light_level - 1) / 10000))) + -- Note: round() is math.floor(val + 0.5) to match st.utils.round + local lux = math.floor(10 ^ ((child_config.light_level - 1) / 10000) + 0.5) + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.illuminanceMeasurement.illuminance(lux) + ) + ) + + -- Battery + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.battery.battery(child_config.battery) + ) + ) + end + + elseif child_config.type == "contact" then + -- Contact sensors emit multiple events from refresh during init (only if SSE enabled) + if self.sse_enabled then + test.socket.capability:__set_channel_ordering("relaxed") + + -- Contact state + local contact_value = (child_config.contact_state == "no_contact") and "open" or "closed" + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.contactSensor.contact[contact_value]() + ) + ) + + -- Tamper state + local tamper_value = (child_config.tamper == "tampered") and "detected" or "clear" + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.tamperAlert.tamper[tamper_value]() + ) + ) + + -- Battery + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.battery.battery(child_config.battery) + ) + ) + end + + elseif child_config.type == "light" then + -- Dimmable lights emit levelRange on init + if profile_has_capability(child_config.profile, capabilities.switchLevel.ID) then + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.switchLevel.levelRange({ minimum = 1, maximum = 100 }) + ) + ) + end + end + end + end + + -- Build configuration objects for each device type + local configs = {} + for i, child_config in ipairs(self.children) do + local device_key = child_config.type + if not configs[device_key] then + configs[device_key] = {} + end + + -- Build config based on device type + if child_config.type == "button" then + table.insert(configs[device_key], { + rid = child_config.rid, + device_id = child_config.state.hue_device_id, + label = child_config.state.hue_provided_name, + num_buttons = child_config.num_buttons, + battery = child_config.battery, + power_rid = child_config.state.power_id, + zigbee_rid = child_config.state.zigbee_connectivity_id or (child_config.rid .. "-zigbee"), + button_rids = {} -- Will be populated if needed + }) + -- Add button RIDs for multi-button devices + for j = 1, child_config.num_buttons do + table.insert(configs[device_key][#configs[device_key]].button_rids, child_config.state["button" .. j .. "_id"]) + end + + elseif child_config.type == "light" then + table.insert(configs[device_key], { + rid = child_config.rid, + device_id = child_config.state.hue_device_id, + label = child_config.state.hue_provided_name, + zigbee_rid = child_config.state.zigbee_connectivity_id or (child_config.rid .. "-zigbee"), + on = child_config.state.on, + dimming = child_config.state.dimming, + color = child_config.state.color, + color_temperature = child_config.state.color_temperature, + mode = child_config.state.mode + }) + + elseif child_config.type == "motion" then + table.insert(configs[device_key], { + rid = child_config.rid, + device_id = child_config.state.hue_device_id, + label = child_config.state.hue_provided_name, + battery = child_config.battery, + motion = child_config.motion, + temperature = child_config.temperature, + light_level = child_config.light_level, + power_rid = child_config.state.power_id, + zigbee_rid = child_config.state.zigbee_connectivity_id or (child_config.rid .. "-zigbee"), + temperature_rid = child_config.state.temperature_id, + light_level_rid = child_config.state.light_level_id + }) + + elseif child_config.type == "contact" then + table.insert(configs[device_key], { + rid = child_config.rid, + device_id = child_config.state.hue_device_id, + label = child_config.state.hue_provided_name, + battery = child_config.battery, + contact_state = child_config.contact_state, + tamper = child_config.tamper, + power_rid = child_config.state.power_id, + zigbee_rid = child_config.state.zigbee_connectivity_id or (child_config.rid .. "-zigbee"), + tamper_rid = child_config.state.tamper_id + }) + end + end + + local hue_device_id_by_rid = {} + for _, child_config in ipairs(self.children) do + hue_device_id_by_rid[child_config.rid] = child_config.state.hue_device_id + end + + --- Apply the rooms declared with `with_room` to the bridge. Must be called from within a test + --- once the light children have been added (e.g. after `test.wait_for_events()`). + local function seed_groups() + local grouped_utils = require "utils.grouped_utils" + local bridge_device = driver_under_test:get_device_info(mock_bridge.id) + -- group_add expects the bridge's group list to already exist (normally set by a group scan). + bridge_device:set_field(Fields.GROUPS, {}) + for _, room in ipairs(self.rooms or {}) do + local children = {} + for _, light_rid in ipairs(room.light_rids) do + table.insert(children, { rid = hue_device_id_by_rid[light_rid], rtype = "device" }) + end + grouped_utils.group_add(driver_under_test, bridge_device, { + id = room.rid, + type = "room", + metadata = { name = room.name }, + children = children, + services = { { rid = room.grouped_light_rid, rtype = "grouped_light" } }, + }) + end + return bridge_device:get_field(Fields.GROUPS) + end + + --- The driver's own device object for a mock device, e.g. to inspect or seed device fields. + --- Must be called from within a test. + local function driver_device(mock_device) + return driver_under_test:get_device_info(mock_device.id) + end + + -- Return structured fixtures object + return { + bridge = mock_bridge, + devices = mock_children, + test_init = test_init, + configs = configs, + seed_groups = seed_groups, + driver_device = driver_device, + } +end + +-- Export HueDeviceBuilder via a constructor function +M.HueDeviceBuilder = { + new = M.HueDeviceBuilder_new +} + +--- ConnectionScenario 2.0 Test Helpers +--- These helpers reduce boilerplate when using the new connection_scenario framework + +--- Create a ConnectionScenario configured for Hue bridge testing. +--- +--- @param options table|nil Configuration options: +--- - host: Bridge IP (default: hue_test_helpers.BRIDGE_IP) +--- - port: Bridge port (default: 443) +--- - rest: Include REST connection (default: true) +--- - rest_name: Name for REST connection (default: "rest") +--- - rest_method: HTTP method(s) for the REST matcher (default: GET and PUT, or just GET when +--- `put` adds a separate PUT connection) +--- - rest_ordering: Ordering for REST connection (default: "relaxed") +--- - sse: Include SSE connection (default: false) +--- - put: Include PUT connection (default: false) +--- - get: Include GET connection (default: false) +--- @return table scenario The ConnectionScenario instance +--- @return table connections Table of connection handles: { rest = ..., sse = ..., put_conn = ..., get_conn = ... } +function M.create_hue_scenario(options) + options = options or {} + local connection_scenario = require "integration_test.connection_scenario" + local http = require "integration_test.connection_scenario_http" + + local scenario = connection_scenario.new({ + host = options.host or M.BRIDGE_IP, + port = options.port or 443 + }) + + local connections = {} + + -- REST connection (default) + if options.rest ~= false then + -- Without a separate PUT connection, REST reads and commands share this one. + local rest_method = options.rest_method or (options.put and "GET" or { "GET", "PUT" }) + connections.rest = scenario:connection(options.rest_name or "rest", { + matcher = http.matcher(rest_method, "/clip/v2/resource/"), + ordering = options.rest_ordering or "relaxed" + }) + end + + -- SSE connection + if options.sse then + connections.sse = scenario:connection("sse", { + matcher = http.matcher("GET", "/eventstream/clip/v2") + }) + end + + -- PUT connection (for light commands) + if options.put then + connections.put_conn = scenario:connection("put_conn", { + matcher = http.matcher("PUT", "/clip/v2/resource/"), + ordering = "relaxed" + }) + end + + -- GET connection (for refresh operations when PUT is also needed) + if options.get then + connections.get_conn = scenario:connection("get_conn", { + matcher = http.matcher("GET", "/clip/v2/resource/"), + ordering = "relaxed" + }) + end + + return scenario, connections +end + +--- Setup test_init function with scenario activation. +--- +--- @param base_test_init function The base test_init function returned by HueDeviceBuilder +--- @param scenario table The ConnectionScenario instance +--- @param additional_setup function|nil Optional additional setup to run before scenario:activate() +function M.setup_scenario_test_init(base_test_init, scenario, additional_setup) + local test = require "integration_test" + local function test_init() + base_test_init() + if additional_setup then + additional_setup() + end + scenario:activate() + end + test.set_test_init_function(test_init) +end + +--- Expect a Hue device info request (GET /clip/v2/resource/device/{id}). +--- +--- @param connection table The connection handle +--- @param device_id string The device ID +--- @param services table Array of service objects +--- @param options table|nil Options: +--- - name: Device name (default: "Device") +--- - metadata: Full metadata table (overrides name) +--- - product_data: Product data table +--- - status: HTTP status (default: 200) +--- - reusable: Make expectation reusable (default: true) +function M.expect_device_info(connection, device_id, services, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + return http.expect_request(connection, "GET", "/clip/v2/resource/device/" .. device_id, { + status = options.status or 200, + body = { + errors = {}, + data = {{ + type = "device", + id = device_id, + metadata = options.metadata or { name = options.name or "Device" }, + product_data = options.product_data, + services = services + }} + }, + reusable = options.reusable ~= false + }) +end + +--- Expect a Hue zigbee connectivity request (GET /clip/v2/resource/zigbee_connectivity/{id}). +--- +--- @param connection table The connection handle +--- @param zigbee_rid string The zigbee connectivity resource ID +--- @param options table|nil Options: +--- - status: HTTP status (default: 200) +--- - connectivity_status: Connection status (default: "connected") +--- - owner: Owner resource ID +--- - reusable: Make expectation reusable (default: false) +function M.expect_zigbee_connectivity(connection, zigbee_rid, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + local data_entry = { + type = "zigbee_connectivity", + id = zigbee_rid, + status = options.connectivity_status or "connected" + } + + if options.owner then + data_entry.owner = { rid = options.owner } + end + + return http.expect_request(connection, "GET", "/clip/v2/resource/zigbee_connectivity/" .. zigbee_rid, { + status = options.status or 200, + body = { + errors = {}, + data = { data_entry } + }, + reusable = options.reusable + }) +end + +--- Expect a Hue device power request (GET /clip/v2/resource/device_power/{id}). +--- +--- @param connection table The connection handle +--- @param power_rid string The device power resource ID +--- @param battery_level number Battery level (0-100) +--- @param options table|nil Options: +--- - status: HTTP status (default: 200) +--- - reusable: Make expectation reusable (default: false) +function M.expect_device_power(connection, power_rid, battery_level, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + return http.expect_request(connection, "GET", "/clip/v2/resource/device_power/" .. power_rid, { + status = options.status or 200, + body = { + errors = {}, + data = {{ + type = "device_power", + id = power_rid, + power_state = { battery_level = battery_level } + }} + }, + reusable = options.reusable + }) +end + +--- Expect a Hue button resource request (GET /clip/v2/resource/button/{id}). +--- +--- @param connection table The connection handle +--- @param button_rid string The button resource ID +--- @param options table|nil Options: +--- - control_id: Button control ID (default: 1) +--- - event_values: Array of supported event values (default: {"short_release", "long_press", "long_release"}) +--- - status: HTTP status (default: 200) +--- - reusable: Make expectation reusable (default: false) +function M.expect_button_resource(connection, button_rid, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + return http.expect_request(connection, "GET", "/clip/v2/resource/button/" .. button_rid, { + status = options.status or 200, + body = { + errors = {}, + data = {{ + type = "button", + id = button_rid, + metadata = { control_id = options.control_id or 1 }, + button = { + button_report = { event = "initial_press", updated = "2024-01-01T00:00:00Z" }, + event_values = options.event_values or { "short_release", "long_press", "long_release" } + } + }} + }, + reusable = options.reusable + }) +end + +--- Expect a Hue motion sensor resource request (GET /clip/v2/resource/motion/{id}). +--- +--- @param connection table The connection handle +--- @param motion_rid string The motion sensor resource ID +--- @param is_active boolean Motion detected state +--- @param options table|nil Options: +--- - status: HTTP status (default: 200) +--- - reusable: Make expectation reusable (default: false) +function M.expect_motion_resource(connection, motion_rid, is_active, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + return http.expect_request(connection, "GET", "/clip/v2/resource/motion/" .. motion_rid, { + status = options.status or 200, + body = { + errors = {}, + data = {{ + type = "motion", + id = motion_rid, + motion = { motion = is_active, motion_valid = true }, + enabled = true + }} + }, + reusable = options.reusable + }) +end + +--- Expect a Hue temperature sensor resource request (GET /clip/v2/resource/temperature/{id}). +--- +--- @param connection table The connection handle +--- @param temperature_rid string The temperature sensor resource ID +--- @param temperature number Temperature in Celsius +--- @param options table|nil Options: +--- - status: HTTP status (default: 200) +--- - reusable: Make expectation reusable (default: false) +function M.expect_temperature_resource(connection, temperature_rid, temperature, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + return http.expect_request(connection, "GET", "/clip/v2/resource/temperature/" .. temperature_rid, { + status = options.status or 200, + body = { + errors = {}, + data = {{ + type = "temperature", + id = temperature_rid, + temperature = { temperature = temperature, temperature_valid = true }, + enabled = true + }} + }, + reusable = options.reusable + }) +end + +--- Expect a Hue light level sensor resource request (GET /clip/v2/resource/light_level/{id}). +--- +--- @param connection table The connection handle +--- @param light_level_rid string The light level sensor resource ID +--- @param light_level number Light level value +--- @param options table|nil Options: +--- - status: HTTP status (default: 200) +--- - reusable: Make expectation reusable (default: false) +function M.expect_light_level_resource(connection, light_level_rid, light_level, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + return http.expect_request(connection, "GET", "/clip/v2/resource/light_level/" .. light_level_rid, { + status = options.status or 200, + body = { + errors = {}, + data = {{ + type = "light_level", + id = light_level_rid, + light = { light_level = light_level, light_level_valid = true }, + enabled = true + }} + }, + reusable = options.reusable + }) +end + +--- Expect a Hue contact sensor resource request (GET /clip/v2/resource/contact/{id}). +--- +--- @param connection table The connection handle +--- @param contact_rid string The contact sensor resource ID +--- @param state string Contact state: "contact" (closed) or "no_contact" (open) +--- @param options table|nil Options: +--- - status: HTTP status (default: 200) +--- - reusable: Make expectation reusable (default: false) +function M.expect_contact_resource(connection, contact_rid, state, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + return http.expect_request(connection, "GET", "/clip/v2/resource/contact/" .. contact_rid, { + status = options.status or 200, + body = { + errors = {}, + data = {{ + type = "contact", + id = contact_rid, + contact_report = { state = state }, + enabled = true + }} + }, + reusable = options.reusable + }) +end + +--- Expect a Hue tamper sensor resource request (GET /clip/v2/resource/tamper/{id}). +--- +--- @param connection table The connection handle +--- @param tamper_rid string The tamper sensor resource ID +--- @param state string Tamper state: "tampered" or "not_tampered" +--- @param options table|nil Options: +--- - status: HTTP status (default: 200) +--- - reusable: Make expectation reusable (default: false) +function M.expect_tamper_resource(connection, tamper_rid, state, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + return http.expect_request(connection, "GET", "/clip/v2/resource/tamper/" .. tamper_rid, { + status = options.status or 200, + body = { + errors = {}, + data = {{ + type = "tamper", + id = tamper_rid, + tamper_reports = {{ state = state }} + }} + }, + reusable = options.reusable + }) +end + +--- Expect a Hue light resource request (GET /clip/v2/resource/light/{id}). +--- +--- @param connection table The connection handle +--- @param light_rid string The light resource ID +--- @param on_state boolean Light on/off state +--- @param brightness number|nil Brightness level (0-100) +--- @param options table|nil Options: +--- - status: HTTP status (default: 200) +--- - color: Color object with xy coordinates +--- - color_temperature: Color temperature object with mirek +--- - reusable: Make expectation reusable (default: false) +function M.expect_light_resource(connection, light_rid, on_state, brightness, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + local light_data = { + type = "light", + id = light_rid, + on = { on = on_state } + } + + if brightness then + light_data.dimming = { brightness = brightness } + end + + if options.color then + light_data.color = options.color + end + + if options.color_temperature then + light_data.color_temperature = options.color_temperature + end + + return http.expect_request(connection, "GET", "/clip/v2/resource/light/" .. light_rid, { + status = options.status or 200, + body = { + errors = {}, + data = { light_data } + }, + reusable = options.reusable + }) +end + +--- Setup SSE connection expectations (handshake + connectivity poll). +--- +--- @param sse_connection table The SSE connection handle +--- @param rest_connection table The REST connection handle +--- @param options table|nil Options: +--- - handshake_reusable: Make handshake expectation reusable (default: true) +--- - poll_reusable: Make connectivity poll expectation reusable (default: false) +function M.setup_sse_expectations(sse_connection, rest_connection, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + -- SSE handshake + http.expect_sse_handshake(sse_connection, "/eventstream/clip/v2", options.handshake_reusable ~= false) + + -- Connectivity poll after SSE opens + http.expect_request(rest_connection, "GET", "/clip/v2/resource/zigbee_connectivity", { + status = 200, + body = { + errors = {}, + data = {{ type = "zigbee_connectivity", status = "connected" }} + }, + reusable = options.poll_reusable + }) +end + +--- SSE Event Builders +--- These helpers create properly structured SSE event tables + +--- Create a button SSE event. +--- +--- @param button_rid string The button resource ID +--- @param event_type string Event type: "short_release", "long_press", "long_release", etc. +--- @param options table|nil Options: +--- - timestamp: Event timestamp (default: "2024-01-01T12:00:00Z") +--- - battery_level: Include battery level in event +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.button_event(button_rid, event_type, options) + options = options or {} + + local button_data = { + type = "button", + id = button_rid, + button = { + button_report = { + event = event_type, + updated = options.timestamp or "2024-01-01T12:00:00Z" + } + } + } + + if options.battery_level then + button_data.power_state = { battery_level = options.battery_level } + end + + return { + type = options.update_type or "update", + data = { button_data } + } +end + +--- Create a motion sensor SSE event. +--- +--- @param motion_rid string The motion sensor resource ID +--- @param is_active boolean Motion detected state +--- @param options table|nil Options: +--- - motion_valid: Motion valid flag (default: true) +--- - battery_level: Include battery level in event +--- - temperature: Include temperature in event +--- - light_level: Include light level in event +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.motion_event(motion_rid, is_active, options) + options = options or {} + + local motion_data = { + type = "motion", + id = motion_rid, + motion = { + motion = is_active, + motion_valid = options.motion_valid ~= false + } + } + + if options.battery_level then + motion_data.power_state = { battery_level = options.battery_level } + end + + if options.temperature then + motion_data.temperature = { + temperature = options.temperature, + temperature_valid = true + } + end + + if options.light_level then + motion_data.light = { + light_level = options.light_level, + light_level_valid = true + } + end + + return { + type = options.update_type or "update", + data = { motion_data } + } +end + +--- Create a contact sensor SSE event. +--- +--- @param contact_rid string The contact sensor resource ID +--- @param state string Contact state: "contact" (closed) or "no_contact" (open) +--- @param options table|nil Options: +--- - battery_level: Include battery level in event +--- - tamper_state: Include tamper state in event +--- - temperature: Include temperature in event +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.contact_event(contact_rid, state, options) + options = options or {} + + local contact_data = { + type = "contact", + id = contact_rid, + contact_report = { state = state } + } + + if options.battery_level then + contact_data.power_state = { battery_level = options.battery_level } + end + + if options.tamper_state then + contact_data.tamper_reports = {{ state = options.tamper_state }} + end + + if options.temperature then + contact_data.temperature = { + temperature = options.temperature, + temperature_valid = true + } + end + + return { + type = options.update_type or "update", + data = { contact_data } + } +end + +--- Create a tamper sensor SSE event. +--- +--- @param tamper_rid string The tamper sensor resource ID +--- @param state string Tamper state: "tampered" or "not_tampered" +--- @param options table|nil Options: +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.tamper_event(tamper_rid, state, options) + options = options or {} + + return { + type = options.update_type or "update", + data = {{ + type = "tamper", + id = tamper_rid, + tamper_reports = {{ state = state }} + }} + } +end + +--- Create a light SSE event. +--- +--- @param light_rid string The light resource ID +--- @param on_state boolean Light on/off state +--- @param brightness number|nil Brightness level (0-100) +--- @param options table|nil Options: +--- - color: Color object with xy coordinates +--- - color_temperature: Color temperature object with mirek +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.light_event(light_rid, on_state, brightness, options) + options = options or {} + + local light_data = { + type = "light", + id = light_rid, + on = { on = on_state } + } + + if brightness then + light_data.dimming = { brightness = brightness } + end + + if options.color then + light_data.color = options.color + end + + if options.color_temperature then + light_data.color_temperature = options.color_temperature + end + + return { + type = options.update_type or "update", + data = { light_data } + } +end + +--- Create a temperature sensor SSE event. +--- +--- @param temperature_rid string The temperature sensor resource ID +--- @param temperature number Temperature in Celsius +--- @param options table|nil Options: +--- - temperature_valid: Temperature valid flag (default: true) +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.temperature_event(temperature_rid, temperature, options) + options = options or {} + + return { + type = options.update_type or "update", + data = {{ + type = "temperature", + id = temperature_rid, + temperature = { + temperature = temperature, + temperature_valid = options.temperature_valid ~= false + } + }} + } +end + +--- Create a light level sensor SSE event. +--- +--- @param light_level_rid string The light level sensor resource ID +--- @param light_level number Light level value +--- @param options table|nil Options: +--- - light_level_valid: Light level valid flag (default: true) +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.light_level_event(light_level_rid, light_level, options) + options = options or {} + + return { + type = options.update_type or "update", + data = {{ + type = "light_level", + id = light_level_rid, + light = { + light_level = light_level, + light_level_valid = options.light_level_valid ~= false + } + }} + } +end + +--- Helper to escape a Hue UUID for use in Lua pattern matching. +--- Converts: "11111111-1111-1111-1111-111111111111" +--- To: "11111111%-1111%-1111%-1111%-111111111111" +--- +--- @param uuid string The UUID to escape +--- @return string Escaped UUID suitable for Lua patterns +function M.escape_uuid(uuid) + return uuid:gsub("%-", "%%-") +end + +--- Device-Type-Specific Init Expectation Setup Helpers +--- These high-level helpers configure all standard init-time expectations for a device type. + +--- Setup standard init-time expectations for a button device. +--- +--- This configures expectations for: +--- - Device info query +--- - Zigbee connectivity query +--- - Button resource queries (one per button) +--- - Device power/battery query +--- - SSE handshake (if sse_connection provided) +--- - Room and zone resource queries (empty responses) +--- +--- @param rest_connection table The REST connection handle +--- @param sse_connection table|nil The SSE connection handle (optional, for SSE-enabled tests) +--- @param button_config table Button configuration with fields: +--- - device_id: Hue device ID +--- - label: Device label/name +--- - zigbee_rid: Zigbee connectivity resource ID +--- - button_rids: Array of button resource IDs +--- - power_rid: Device power resource ID +--- - battery: Battery level (0-100) +--- @param options table|nil Options: +--- - services: Override default services array +--- - reusable: Make device info reusable (default: true) +function M.setup_button_init_expectations(rest_connection, sse_connection, button_config, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + -- Default services if not provided + local services = options.services or { + { rtype = "zigbee_connectivity", rid = button_config.zigbee_rid }, + { rtype = "button", rid = button_config.button_rids[1] }, + { rtype = "device_power", rid = button_config.power_rid } + } + + -- 1. Device info + M.expect_device_info(rest_connection, button_config.device_id, services, { + name = button_config.label, + product_data = { product_name = button_config.label }, + reusable = options.reusable ~= false + }) + + -- 2. Zigbee connectivity + M.expect_zigbee_connectivity(rest_connection, button_config.zigbee_rid) + + -- 3. Button resources (one per button) + for _, button_rid in ipairs(button_config.button_rids) do + M.expect_button_resource(rest_connection, button_rid) + end + + -- 4. Device power + M.expect_device_power(rest_connection, button_config.power_rid, button_config.battery) + + -- 5. SSE handshake (if SSE enabled) + if sse_connection then + M.setup_sse_expectations(sse_connection, rest_connection) + end + + -- 6. Room resource query (empty, reusable) + http.expect_request(rest_connection, "GET", "/clip/v2/resource/room", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true + }) + + -- 7. Zone resource query (empty, reusable) + http.expect_request(rest_connection, "GET", "/clip/v2/resource/zone", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true + }) +end + +--- Setup standard init-time expectations for a light device. +--- +--- This configures expectations for: +--- - Device info query +--- - Zigbee connectivity query +--- - Light resource query with initial state +--- - Room and zone resource queries (empty responses) +--- +--- @param rest_connection table The REST connection handle +--- @param light_config table Light configuration with fields: +--- - device_id: Hue device ID +--- - rid: Light resource ID +--- - label: Device label/name +--- - zigbee_rid: Zigbee connectivity resource ID +--- - on: On state table { on = boolean } +--- - dimming: Dimming table { brightness = number } +--- - color: Optional color table +--- - color_temperature: Optional color temperature table +--- @param options table|nil Options: +--- - services: Override default services array +--- - reusable: Make expectations reusable (default: true) +function M.setup_light_init_expectations(rest_connection, light_config, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + -- Default services if not provided + local services = options.services or { + { rtype = "zigbee_connectivity", rid = light_config.zigbee_rid }, + { rtype = "light", rid = light_config.rid } + } + + -- 1. Device info + M.expect_device_info(rest_connection, light_config.device_id, services, { + name = light_config.label, + reusable = options.reusable ~= false + }) + + -- 2. Zigbee connectivity + M.expect_zigbee_connectivity(rest_connection, light_config.zigbee_rid, { + reusable = options.reusable ~= false + }) + + -- 3. Light resource with state + local light_body = { + id = light_config.rid, + on = light_config.on, + dimming = light_config.dimming + } + if light_config.color then + light_body.color = light_config.color + end + if light_config.color_temperature then + light_body.color_temperature = light_config.color_temperature + end + if light_config.mode then + light_body.mode = light_config.mode + end + + http.expect_request(rest_connection, "GET", "/clip/v2/resource/light/" .. light_config.rid, { + status = 200, + body = { errors = {}, data = { light_body } }, + reusable = options.reusable ~= false + }) + + -- 4. Room resource query (empty, reusable) + http.expect_request(rest_connection, "GET", "/clip/v2/resource/room", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true + }) + + -- 5. Zone resource query (empty, reusable) + http.expect_request(rest_connection, "GET", "/clip/v2/resource/zone", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true + }) +end + +--- Setup standard init-time expectations for a motion sensor device. +--- +--- This configures expectations for: +--- - Device info query +--- - Zigbee connectivity query +--- - Motion sensor resource query +--- - Temperature sensor resource query +--- - Light level sensor resource query +--- - Device power/battery query +--- - SSE handshake (if sse_connection provided) +--- - Room and zone resource queries (empty responses) +--- +--- @param rest_connection table The REST connection handle +--- @param sse_connection table|nil The SSE connection handle (optional, for SSE-enabled tests) +--- @param motion_config table Motion sensor configuration with fields: +--- - device_id: Hue device ID +--- - rid: Motion sensor resource ID +--- - label: Device label/name +--- - zigbee_rid: Zigbee connectivity resource ID +--- - motion: Motion detected state (boolean) +--- - temperature: Temperature in Celsius +--- - temperature_rid: Temperature sensor resource ID +--- - light_level: Light level value +--- - light_level_rid: Light level sensor resource ID +--- - power_rid: Device power resource ID +--- - battery: Battery level (0-100) +--- @param options table|nil Options: +--- - services: Override default services array +--- - reusable: Make device info reusable (default: true) +function M.setup_motion_init_expectations(rest_connection, sse_connection, motion_config, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + -- Default services if not provided + local services = options.services or { + { rtype = "zigbee_connectivity", rid = motion_config.zigbee_rid }, + { rtype = "motion", rid = motion_config.rid }, + { rtype = "temperature", rid = motion_config.temperature_rid }, + { rtype = "light_level", rid = motion_config.light_level_rid }, + { rtype = "device_power", rid = motion_config.power_rid } + } + + -- 1. Device info + M.expect_device_info(rest_connection, motion_config.device_id, services, { + name = motion_config.label, + reusable = options.reusable ~= false + }) + + -- 2. Zigbee connectivity + M.expect_zigbee_connectivity(rest_connection, motion_config.zigbee_rid) + + -- 3. Motion sensor resource + M.expect_motion_resource(rest_connection, motion_config.rid, motion_config.motion) + + -- 4. Temperature sensor resource + M.expect_temperature_resource(rest_connection, motion_config.temperature_rid, motion_config.temperature) + + -- 5. Light level sensor resource + M.expect_light_level_resource(rest_connection, motion_config.light_level_rid, motion_config.light_level) + + -- 6. Device power + M.expect_device_power(rest_connection, motion_config.power_rid, motion_config.battery) + + -- 7. SSE handshake (if SSE enabled) + if sse_connection then + M.setup_sse_expectations(sse_connection, rest_connection) + end + + -- 8. Room resource query (empty, reusable) + http.expect_request(rest_connection, "GET", "/clip/v2/resource/room", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true + }) + + -- 9. Zone resource query (empty, reusable) + http.expect_request(rest_connection, "GET", "/clip/v2/resource/zone", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true + }) +end + +--- Setup standard init-time expectations for a contact sensor device. +--- +--- This configures expectations for: +--- - Device info query +--- - Zigbee connectivity query +--- - Contact sensor resource query +--- - Tamper sensor resource query +--- - Device power/battery query +--- - SSE handshake (if sse_connection provided) +--- - Room and zone resource queries (empty responses) +--- +--- @param rest_connection table The REST connection handle +--- @param sse_connection table|nil The SSE connection handle (optional, for SSE-enabled tests) +--- @param contact_config table Contact sensor configuration with fields: +--- - device_id: Hue device ID +--- - rid: Contact sensor resource ID +--- - label: Device label/name +--- - zigbee_rid: Zigbee connectivity resource ID +--- - contact_state: Contact state ("contact" = closed, "no_contact" = open) +--- - tamper: Tamper state ("tampered" or "not_tampered") +--- - tamper_rid: Tamper sensor resource ID +--- - power_rid: Device power resource ID +--- - battery: Battery level (0-100) +--- @param options table|nil Options: +--- - services: Override default services array +--- - reusable: Make device info reusable (default: true) +function M.setup_contact_init_expectations(rest_connection, sse_connection, contact_config, options) + options = options or {} + local http = require "integration_test.connection_scenario_http" + + -- Default services if not provided + local services = options.services or { + { rtype = "zigbee_connectivity", rid = contact_config.zigbee_rid }, + { rtype = "contact", rid = contact_config.rid }, + { rtype = "tamper", rid = contact_config.tamper_rid }, + { rtype = "device_power", rid = contact_config.power_rid } + } + + -- 1. Device info + M.expect_device_info(rest_connection, contact_config.device_id, services, { + name = contact_config.label, + reusable = options.reusable ~= false + }) + + -- 2. Zigbee connectivity + M.expect_zigbee_connectivity(rest_connection, contact_config.zigbee_rid) + + -- 3. Contact sensor resource + M.expect_contact_resource(rest_connection, contact_config.rid, contact_config.contact_state) + + -- 4. Tamper sensor resource + M.expect_tamper_resource(rest_connection, contact_config.tamper_rid, contact_config.tamper) + + -- 5. Device power + M.expect_device_power(rest_connection, contact_config.power_rid, contact_config.battery) + + -- 6. SSE handshake (if SSE enabled) + if sse_connection then + M.setup_sse_expectations(sse_connection, rest_connection) + end + + -- 7. Room resource query (empty, reusable) + http.expect_request(rest_connection, "GET", "/clip/v2/resource/room", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true + }) + + -- 8. Zone resource query (empty, reusable) + http.expect_request(rest_connection, "GET", "/clip/v2/resource/zone", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true + }) +end + +return M diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua new file mode 100644 index 0000000000..ada8dbf96d --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua @@ -0,0 +1,251 @@ +--- Tests for batched capability command handling: commands received together are sent to the +--- bridge as a single grouped_light request when they exactly cover a Hue room, and as +--- per-light requests otherwise. + +-- Opt in to batched receives before requiring the framework: the driver checks for +-- `receive_batch` when it is loaded, and installs its batch handler only if it exists. +-- luacheck: globals _capability_batch_receive +_capability_batch_receive = true + +local test = require "integration_test" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local Fields = require "fields" + +local LIGHT_1_RID = "11111111-1111-1111-1111-111111111111" +local LIGHT_2_RID = "22222222-2222-2222-2222-222222222222" +local LIGHT_3_RID = "33333333-3333-3333-3333-333333333333" +local ROOM_RID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" +local GROUPED_LIGHT_RID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + +local function light_state() + return { + on = { on = true }, + dimming = { brightness = 100 }, + color = { xy = { x = 0.3, y = 0.3 }, gamut = { red = { x = 0.7, y = 0.3 }, green = { x = 0.2, y = 0.7 }, blue = { x = 0.15, y = 0.05 } } }, + color_temperature = { mirek = 366, mirek_schema = { mirek_minimum = 153, mirek_maximum = 500 } }, + mode = "normal", + } +end + +-- Lights 1 and 2 make up the room; light 3 is outside it. +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LIGHT_1_RID, light_state(), "white-and-color-ambiance.yml") + :with_light(LIGHT_2_RID, light_state(), "white-and-color-ambiance.yml") + :with_light(LIGHT_3_RID, light_state(), "white-and-color-ambiance.yml") + :with_room(ROOM_RID, GROUPED_LIGHT_RID, { LIGHT_1_RID, LIGHT_2_RID }, "Living Room") + :start() + +local light_1, light_2, light_3 = fixtures.devices[1], fixtures.devices[2], fixtures.devices[3] + +local scenario, conns = hue_test_helpers.create_hue_scenario({ rest = true, rest_method = "PUT" }) +local rest = conns.rest + +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +local function switch_cmd(command) + return { capability = "switch", component = "main", command = command, args = {} } +end + +local function step_level_cmd(step_size) + return { capability = "statelessSwitchLevelStep", component = "main", command = "stepLevel", args = { step_size } } +end + +local LIGHT_PATH = "/clip/v2/resource/light/" +local GROUPED_LIGHT_PATH = "/clip/v2/resource/grouped_light/" + +local function expect_light_put(rid) + http.expect_request_for_test(rest, "PUT", LIGHT_PATH .. rid, { + status = 200, + body = { data = { { rid = rid, rtype = "light" } } }, + }) +end + +local function expect_grouped_light_put(rid) + http.expect_request_for_test(rest, "PUT", GROUPED_LIGHT_PATH .. rid, { + status = 200, + body = { data = { { rid = rid, rtype = "grouped_light" } } }, + }) +end + +local function assert_requests(method, path, expected) + -- `path` is either a full resource path or a collection prefix like LIGHT_PATH. + local actual = http.count_requests(rest, method, path, { prefix = true }) + assert(actual == expected, + string.format("expected %d %s %s request(s), got %d", expected, method, path, actual)) +end + +-- Let the lights finish initializing, then give the bridge its rooms. +local function init_and_seed_groups() + test.wait_for_events() + local groups = fixtures.seed_groups() + assert(#groups == 1 and #groups[1].devices == 2, + "expected 1 room with 2 light records, got " .. #groups .. " rooms") +end + +test.register_coroutine_test( + "a batch of one command is sent as a single light request", + function() + init_and_seed_groups() + expect_light_put(LIGHT_1_RID) + + test.socket.capability:__queue_receive_batch({ { light_1.id, switch_cmd("on") } }) + test.wait_for_events() + + assert_requests("PUT", LIGHT_PATH .. LIGHT_1_RID, 1) + assert_requests("PUT", GROUPED_LIGHT_PATH, 0) + http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_1_RID, { on = { on = true } }) + end +) + +test.register_coroutine_test( + "a batch covering every light in a room is sent as one grouped_light request", + function() + init_and_seed_groups() + expect_grouped_light_put(GROUPED_LIGHT_RID) + + test.socket.capability:__queue_receive_batch({ + { light_1.id, switch_cmd("on") }, + { light_2.id, switch_cmd("on") }, + }) + test.wait_for_events() + + assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 1) + assert_requests("PUT", LIGHT_PATH, 0) + http.assert_sent_json(rest, "PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, { on = { on = true } }) + end +) + +test.register_coroutine_test( + "lights outside the matched room are still sent individually", + function() + init_and_seed_groups() + expect_grouped_light_put(GROUPED_LIGHT_RID) + expect_light_put(LIGHT_3_RID) + + test.socket.capability:__queue_receive_batch({ + { light_1.id, switch_cmd("off") }, + { light_2.id, switch_cmd("off") }, + { light_3.id, switch_cmd("off") }, + }) + test.wait_for_events() + + assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 1) + assert_requests("PUT", LIGHT_PATH .. LIGHT_3_RID, 1) + assert_requests("PUT", LIGHT_PATH, 1) + end +) + +test.register_coroutine_test( + "matching commands that don't cover a whole room are sent individually", + function() + init_and_seed_groups() + expect_light_put(LIGHT_1_RID) + expect_light_put(LIGHT_3_RID) + + test.socket.capability:__queue_receive_batch({ + { light_1.id, switch_cmd("on") }, + { light_3.id, switch_cmd("on") }, + }) + test.wait_for_events() + + assert_requests("PUT", LIGHT_PATH .. LIGHT_1_RID, 1) + assert_requests("PUT", LIGHT_PATH .. LIGHT_3_RID, 1) + assert_requests("PUT", GROUPED_LIGHT_PATH, 0) + end +) + +test.register_coroutine_test( + "commands with different arguments are not grouped", + function() + init_and_seed_groups() + expect_light_put(LIGHT_1_RID) + expect_light_put(LIGHT_2_RID) + + test.socket.capability:__queue_receive_batch({ + { light_1.id, switch_cmd("on") }, + { light_2.id, switch_cmd("off") }, + }) + test.wait_for_events() + + assert_requests("PUT", LIGHT_PATH .. LIGHT_1_RID, 1) + assert_requests("PUT", LIGHT_PATH .. LIGHT_2_RID, 1) + assert_requests("PUT", GROUPED_LIGHT_PATH, 0) + http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_1_RID, { on = { on = true } }) + http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_2_RID, { on = { on = false } }) + end +) + +test.register_coroutine_test( + "commands without a grouped handler are sent individually even for a whole room", + function() + init_and_seed_groups() + expect_light_put(LIGHT_1_RID) + expect_light_put(LIGHT_2_RID) + + test.socket.capability:__queue_receive_batch({ + { light_1.id, step_level_cmd(10) }, + { light_2.id, step_level_cmd(10) }, + }) + test.wait_for_events() + + assert_requests("PUT", LIGHT_PATH .. LIGHT_1_RID, 1) + assert_requests("PUT", LIGHT_PATH .. LIGHT_2_RID, 1) + assert_requests("PUT", GROUPED_LIGHT_PATH, 0) + rest:assert_sent('"dimming_delta"') + end +) + +local function set_level_cmd(level) + return { capability = "switchLevel", component = "main", command = "setLevel", args = { level } } +end + +local function set_switch_state(mock_light, state) + fixtures.driver_device(mock_light):set_field(Fields.SWITCH_STATE, state, { persist = true }) +end + +test.register_coroutine_test( + "a grouped setLevel for a room that is already on only sends the brightness", + function() + init_and_seed_groups() + set_switch_state(light_1, "on") + set_switch_state(light_2, "on") + expect_grouped_light_put(GROUPED_LIGHT_RID) + + test.socket.capability:__queue_receive_batch({ + { light_1.id, set_level_cmd(23) }, + { light_2.id, set_level_cmd(23) }, + }) + test.wait_for_events() + + assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 1) + http.assert_sent_json(rest, "PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, { dimming = { brightness = 23 } }) + for _, request in ipairs(http.find_requests(rest, "PUT", GROUPED_LIGHT_PATH, { prefix = true })) do + assert(request.json.on == nil, "expected no on/off request for a room that is already on") + end + end +) + +test.register_coroutine_test( + "a grouped setLevel turns the room on first when one of its lights is off", + function() + init_and_seed_groups() + set_switch_state(light_1, "on") + set_switch_state(light_2, "off") + expect_grouped_light_put(GROUPED_LIGHT_RID) + expect_grouped_light_put(GROUPED_LIGHT_RID) + + test.socket.capability:__queue_receive_batch({ + { light_1.id, set_level_cmd(23) }, + { light_2.id, set_level_cmd(23) }, + }) + test.wait_for_events() + + assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 2) + http.assert_sent_json(rest, "PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, { on = { on = true } }) + http.assert_sent_json(rest, "PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, { dimming = { brightness = 23 } }) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua new file mode 100644 index 0000000000..0c783518ea --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua @@ -0,0 +1,173 @@ +local test = require "integration_test" +local lan_test_utils = require "integration_test.lan_test_utils" +local mock_mdns = require "integration_test.mock_mdns" +local mock_devices_api = require "integration_test.mock_devices_api" + +local Discovery = require "disco" + +local BRIDGE_IP = "192.168.1.20" +local BRIDGE_MAC = "aa-bb-cc-dd-ee-ff" +local BRIDGE_DNI = "AABBCCDDEEFF" -- BRIDGE_MAC with separators stripped, uppercased +local BRIDGE_NAME = "Living Room" + +test.add_test_env_setup_func(function(driver) + -- disco is a module-level singleton that persists across tests within this file; a stale + -- discovery_active=true (e.g. left over from an interrupted prior run) would make + -- HueDiscovery.discover silently no-op. + Discovery.discovery_active = false + Discovery.api_keys = {} + Discovery.disco_api_instances = {} +end) + +local function test_init() + -- No bridge device is pre-registered here -- discovering and creating it is exactly what + -- these tests exercise. +end + +test.set_test_init_function(test_init) + +--- Queue an mDNS response for the bridge and start discovery via the same "discovery" channel +--- message the real hub sends when a user initiates a scan (see +--- st.handlers.discovery_message_handlers), rather than invoking Discovery.discover directly: +--- that function makes real (mocked) blocking REST calls internally via cosock, which only +--- works correctly inside a real cosock-managed thread -- exactly what the framework's own +--- discovery dispatch spins up, and what the test coroutine itself is not. +local function start_discovery() + mock_mdns.__queue_response(Discovery.ServiceType, Discovery.Domain, { + found = { + mock_mdns.build_event({ + name = "Hue Bridge", + service_type = Discovery.ServiceType, + domain = Discovery.Domain, + address = BRIDGE_IP, + port = 443, + }), + }, + }) + test.socket.discovery:__queue_receive({ "start", {} }) +end + +--- Discovery.discover loops "scan, sleep 1s" until told to stop; without this it would keep +--- retrying (and re-sending requests) forever. +local function stop_discovery() + test.socket.discovery:__queue_receive({ "stop" }) +end + +test.register_coroutine_test( + "mDNS discovery finds a bridge and requests an API key, but does not create a device if the Link Button hasn't been pressed", + function() + local bridge_server = lan_test_utils.build_mock_server(BRIDGE_IP, 443) + bridge_server:queue_http_response(200, {}, { + mac = BRIDGE_MAC, + swversion = "1968054000", + modelid = "BSB002", + name = BRIDGE_NAME, + }) + bridge_server:queue_http_response(200, {}, { + { error = { type = 101, address = "/", description = "link button not pressed" } }, + }) + + start_discovery() + test.wait_for_events() + stop_discovery() + test.wait_for_events() + + bridge_server:assert_http_request_received("GET", "/api/config") + bridge_server:assert_http_request_received( + "POST", + "/api", + { body = { devicetype = "smartthings_edge_driver#" .. BRIDGE_IP, generateclientkey = true } } + ) + end +) + +test.register_coroutine_test( + "mDNS discovery creates a bridge device once an API key is obtained", + function() + local bridge_server = lan_test_utils.build_mock_server(BRIDGE_IP, 443) + bridge_server:queue_http_response(200, {}, { + mac = BRIDGE_MAC, + swversion = "1968054000", + modelid = "BSB002", + name = BRIDGE_NAME, + }) + bridge_server:queue_http_response(200, {}, { + { success = { username = "new-bridge-api-key", client_key = "some-client-key" } }, + }) + + mock_devices_api.__expect_create_device({ + deviceNetworkId = BRIDGE_DNI, + label = BRIDGE_NAME, + profileReference = "hue-bridge", + manufacturer = "Signify Netherlands B.V.", + model = "BSB002", + vendorProvidedLabel = BRIDGE_NAME, + type = "LAN", + }) + + start_discovery() + test.wait_for_events() + stop_discovery() + test.wait_for_events() + + bridge_server:assert_http_request_received("GET", "/api/config") + bridge_server:assert_http_request_received( + "POST", + "/api", + { body = { devicetype = "smartthings_edge_driver#" .. BRIDGE_IP, generateclientkey = true } } + ) + end +) + +test.register_coroutine_test( + "with two bridges on the network, only the one that hands out an API key gets a device", + function() + -- Physical testing had a second, never-paired bridge on the same network; both answered + -- discovery (recorded /api/config payloads), and only the paired one returned a key. + local capture_payloads = require "test.fixtures.capture_payloads" + local PAIRED_IP, UNPAIRED_IP = "192.168.1.30", "192.168.1.31" + + mock_mdns.__queue_response(Discovery.ServiceType, Discovery.Domain, { + found = { + mock_mdns.build_event({ name = "Hue Bridge", service_type = Discovery.ServiceType, + domain = Discovery.Domain, address = PAIRED_IP, port = 443 }), + mock_mdns.build_event({ name = "Hue Bridge - 2", service_type = Discovery.ServiceType, + domain = Discovery.Domain, address = UNPAIRED_IP, port = 443 }), + }, + }) + + local paired = lan_test_utils.build_mock_server(PAIRED_IP, 443) + paired:queue_http_response(200, {}, capture_payloads.get("bridge_config")) + paired:queue_http_response(200, {}, { + { success = { username = "new-bridge-api-key", clientkey = "some-client-key" } }, + }) + local unpaired = lan_test_utils.build_mock_server(UNPAIRED_IP, 443) + unpaired:queue_http_response(200, {}, capture_payloads.get("unpaired_bridge_config")) + unpaired:queue_http_response(200, {}, { + { error = { type = 101, address = "", description = "link button not pressed" } }, + }) + + -- The recorded paired bridge's MAC is 00:17:88:00:00:01. + mock_devices_api.__expect_create_device({ + deviceNetworkId = "001788000001", + label = "Hue Bridge", + profileReference = "hue-bridge", + manufacturer = "Signify Netherlands B.V.", + model = "BSB002", + vendorProvidedLabel = "Hue Bridge", + type = "LAN", + }) + + test.socket.discovery:__queue_receive({ "start", {} }) + test.wait_for_events() + stop_discovery() + test.wait_for_events() + + paired:assert_http_request_received("GET", "/api/config") + paired:assert_http_request_received("POST", "/api") + unpaired:assert_http_request_received("GET", "/api/config") + unpaired:assert_http_request_received("POST", "/api") + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_resource_events.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_resource_events.lua new file mode 100644 index 0000000000..5b169a0598 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_resource_events.lua @@ -0,0 +1,124 @@ +--- Tests for bridge resource add/delete events over SSE, replaying event payloads recorded on real +--- hardware (see test/fixtures/capture_payloads.lua): a colour lamp being added to the bridge, the +--- smart plug being removed from it, and resource events the driver has no use for. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local mock_devices_api = require "integration_test.mock_devices_api" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local capture_payloads = require "test.fixtures.capture_payloads" + +local ids = capture_payloads.ids + +local function plug_light_resource() + local resp = capture_payloads.get("plug_light") + return resp +end + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(ids.plug_light, { on = { on = true }, hue_device_id = ids.plug_device, label = "Hue smart plug 1" }, + "plug.yml") + :enable_sse() + :start() + +local mock_bridge, mock_plug = fixtures.bridge, fixtures.devices[1] + +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +http.expect_request(rest, "GET", "/clip/v2/resource/device/" .. ids.plug_device, { + status = 200, body = capture_payloads.get("plug_device"), reusable = true, +}) +hue_test_helpers.expect_zigbee_connectivity(rest, ids.plug_zigbee, { owner = ids.plug_device, reusable = true }) +http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. ids.plug_light, { + status = 200, body = plug_light_resource(), reusable = true, +}) + +local function expect_plug_refresh_events() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities["samsungim.hueSyncMode"].mode("normal"))) + test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities.switch.switch.on())) +end + +test.set_test_init_function(function() + -- Answered once: these tests never reconnect the event stream. + http.expect_sse_handshake(sse, "/eventstream/clip/v2", false) + scenario:activate() + http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true, + persistent = false, + }) + + expect_plug_refresh_events() -- .added's injected refresh + fixtures.test_init() + expect_plug_refresh_events() -- .init's injected refresh +end) + +local function sse_conn() + test.wait_for_events() + return scenario:get_connection("sse") +end + +test.register_coroutine_test( + "a recorded colour lamp add creates exactly one child device", + function() + local conn = sse_conn() + + -- The bridge sends the device together with its zigbee_connectivity, light, entertainment, + -- motion_area_candidate and device_software_update resources; only the device add matters. + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. ids.color_lamp_light, { + status = 200, body = capture_payloads.get("color_lamp_light"), + }) + mock_devices_api.__expect_create_device({ + type = "EDGE_CHILD", + label = "Hue color lamp 1", + profileReference = "white-and-color-ambiance", + parentDeviceId = mock_bridge.id, + manufacturer = "Signify Netherlands B.V.", + model = "LCT014", + parentAssignedChildKey = "light:" .. ids.color_lamp_light, + }) + + http.queue_sse_event(conn, capture_payloads.get("color_lamp_added")) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "resource events for things the driver doesn't model are ignored", + function() + local conn = sse_conn() + + for _, name in ipairs({ + "noise_homekit", + "noise_discovery_geolocation", + "noise_behavior_script", + "noise_behavior_instance", + }) do + http.queue_sse_event(conn, capture_payloads.get(name)) + end + test.wait_for_events() + + assert(mock_devices_api.__is_device_online(mock_bridge.id) == true, "expected the bridge to stay online") + assert(mock_devices_api.__is_device_online(mock_plug.id) == true, "expected the plug to stay online") + assert(not mock_devices_api.__is_device_deleted(mock_plug.id), "expected the plug not to be deleted") + end +) + +test.register_coroutine_test( + "a recorded smart plug removal deletes the plug", + function() + local conn = sse_conn() + + -- Recorded alongside the delete: the plug's room and bridge_home dropping it as a child. + http.queue_sse_event(conn, capture_payloads.get("plug_deleted")) + test.wait_until(function() return mock_devices_api.__is_device_deleted(mock_plug.id) end, + { message = "the plug being deleted after the bridge reported its light resource deleted" }) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua new file mode 100644 index 0000000000..a05ac94df9 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua @@ -0,0 +1,293 @@ +--- Test for Hue bridge SSE connection lifecycle. +--- Migrated to use ConnectionScenario 2.0. +--- +--- IMPORTANT PATTERN: Mixing Declarative Expectations Across Test Phases +--- +--- ConnectionScenario's activate() resets expectation_index to 1 and removes non-persistent +--- expectations. This means you CANNOT define all expectations before activate() if different +--- tests need different responses to the same URL. +--- +--- The solution: Add test-specific expectations AFTER activate() with persistent=false +--- +--- Example from this file: +--- 1. test_init adds shared expectations (refreshes, SSE handshake) +--- 2. test_init calls activate() - this locks in persistent expectations and resets state +--- 3. test_init adds the DEFAULT connectivity expectation AFTER activate() with persistent=false +--- 4. Each test can add its OWN connectivity expectation that overrides the default +--- +--- Why this works: +--- - activate() resets expectation_index to 1 +--- - Driver consumes expectations 1-N during init +--- - expectation_index advances past the default expectations +--- - Test body adds NEW expectations at index N+1, N+2, etc. +--- - These new expectations are checked when driver makes reconnect requests +--- - persistent=false ensures they don't interfere with other tests +--- +--- DO NOT try to use queue_bytes() (interactive pattern) for this - it doesn't work reliably +--- because you can't time when to queue the bytes relative to when the driver makes requests. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local mock_devices_api = require "integration_test.mock_devices_api" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" + +local LIGHT_RID = "22222222-2222-2222-2222-222222222222" +local HUE_DEVICE_ID = "device-uuid-1" +local ZIGBEE_RID = "zigbee-conn-1" + +local NEW_DEVICE_RID = "66666666-6666-6666-6666-666666666666" +local NEW_LIGHT_RID = "77777777-7777-7777-7777-777777777777" +local NEW_LIGHT_NAME = "New Hue Light" + +-- Create test fixture using HueDeviceBuilder +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LIGHT_RID, { + on = { on = true }, + dimming = { brightness = 80 }, + hue_device_id = HUE_DEVICE_ID, + }) + :enable_sse() + :start() + +local mock_bridge, mock_light = fixtures.bridge, fixtures.devices[1] + +-- Create connection scenario with REST and SSE connections +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- Helper to expect switch and level events +local function expect_switch_and_level_emit() + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switch.switch.on()) + ) + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switchLevel.level(80)) + ) +end + +-- Helper to queue the initial light refresh responses (called twice during init) +local function queue_initial_light_refresh(rest_conn, light_on, light_brightness) + light_on = light_on == nil and true or light_on + light_brightness = light_brightness or 80 + + http.expect_request(rest_conn, "GET", "/clip/v2/resource/device/" .. HUE_DEVICE_ID, { + status = 200, + body = { + errors = {}, + data = { { services = { { rtype = "zigbee_connectivity", rid = ZIGBEE_RID } } } }, + } + }) + + http.expect_request(rest_conn, "GET", "/clip/v2/resource/zigbee_connectivity/" .. ZIGBEE_RID, { + status = 200, + body = { + errors = {}, + data = { { owner = { rid = HUE_DEVICE_ID }, status = "connected" } }, + } + }) + + http.expect_request(rest_conn, "GET", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { + errors = {}, + data = { { id = LIGHT_RID, on = { on = light_on }, dimming = { brightness = light_brightness } } }, + } + }) +end + +-- Custom test_init that pre-queues all init expectations before activating scenario +test.set_test_init_function(function() + -- Pre-queue all REST and SSE expectations for the init lifecycle + queue_initial_light_refresh(rest, true, 80) -- .added's injected refresh + queue_initial_light_refresh(rest, true, 80) -- .init's injected refresh + + -- SSE handshake (reusable for reconnections) + http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + + -- NOTE: No connectivity poll expectation here! + -- Each test must add its own connectivity expectation after activate() + + -- Now activate scenario BEFORE running init, so expectations are ready + scenario:activate() + + -- After activate(), add the initial connectivity poll expectation + -- This is non-persistent so it only applies to this test run + http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + status = 200, + body = { + errors = {}, + data = { { owner = { rid = "unrelated-device-not-in-fixture" }, status = "connected" } }, + }, + reusable = false, + persistent = false + }) + + expect_switch_and_level_emit() -- from .added's injected refresh + fixtures.test_init() -- registers .init's levelRange emit + expect_switch_and_level_emit() -- from .init's injected refresh +end) + +--- Verifies that the SSE connection completed successfully during init. +local function connect_sse() + test.wait_for_events() + local sse_conn = scenario:get_connection("sse") + local rest_conn = scenario:get_connection("rest") + + test.wait_for_events() + + assert(mock_devices_api.__is_device_online(mock_bridge.id) == true, + "expected the bridge to be marked online after the SSE connection opened") + assert(mock_devices_api.__is_device_online(mock_light.id) == true, + "expected the light to be marked online from its own refresh's zigbee-connectivity check") + + return sse_conn, rest_conn +end + +test.register_coroutine_test( + "SSE connect marks the bridge online", + function() + connect_sse() + end +) + +test.register_coroutine_test( + "an SSE update event for a light emits that light's attribute events", + function() + local sse_conn = connect_sse() + + http.queue_sse_event(sse_conn, { + hue_test_helpers.light_event(LIGHT_RID, false, 42) + }) + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switch.switch.off()) + ) + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switchLevel.level(42)) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "an SSE add event for a new device creates it", + function() + local sse_conn, rest_conn = connect_sse() + + mock_devices_api.__expect_create_device({ + type = "EDGE_CHILD", + label = NEW_LIGHT_NAME, + profileReference = "white", + parentDeviceId = mock_bridge.id, + manufacturer = "Signify Netherlands B.V.", + model = "TEST", + parentAssignedChildKey = "light:" .. NEW_LIGHT_RID, + }) + + http.queue_sse_event(sse_conn, { + { + type = "add", + data = { + { + id = NEW_DEVICE_RID, + id_v1 = "/lights/9", + type = "device", + metadata = { name = NEW_LIGHT_NAME }, + product_data = { + manufacturer_name = "Signify Netherlands B.V.", + model_id = "TEST", + product_name = "Hue Light", + }, + services = { { rtype = "light", rid = NEW_LIGHT_RID } }, + }, + }, + }, + }) + + -- The driver queries the new light's state + http.expect_request(rest_conn, "GET", "/clip/v2/resource/light/" .. NEW_LIGHT_RID, { + status = 200, + body = { + errors = {}, + data = { + { + id = NEW_LIGHT_RID, + type = "light", + owner = { rid = NEW_DEVICE_RID }, + metadata = { name = NEW_LIGHT_NAME }, + on = { on = true }, + dimming = { brightness = 100 }, + }, + }, + } + }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "an SSE delete event for a device deletes it", + function() + local sse_conn = connect_sse() + + http.queue_sse_event(sse_conn, { + { type = "delete", data = { { type = "light", id = LIGHT_RID } } }, + }) + test.wait_for_events() + + assert(mock_devices_api.__is_device_deleted(mock_light.id) == true, + "expected the light to be deleted after an SSE delete event for its resource id") + end +) + +test.register_coroutine_test( + "a dropped SSE connection marks everything offline, then reconnecting brings it back online", + function() + local sse_conn, rest_conn = connect_sse() + + sse_conn:close_connection() + + test.wait_for_events() + + assert(mock_devices_api.__is_device_online(mock_bridge.id) == false, + "expected the bridge to be marked offline after the SSE connection errored") + assert(mock_devices_api.__is_device_online(mock_light.id) == false, + "expected the light to be marked offline after the SSE connection errored") + + -- Add test-specific expectations for the reconnect sequence + -- These get appended to the expectations array. Since expectation_index is already + -- past the init expectations, these new expectations will be matched when the + -- driver makes reconnect requests. + -- + -- KEY INSIGHT: The default connectivity expectation from test_init was marked + -- persistent=false, so it was consumed during init and won't match again. + -- This allows us to provide a DIFFERENT connectivity response for the reconnect. + + -- Connectivity poll response (returns the light as connected, unlike init) + http.expect_request(rest_conn, "GET", "/clip/v2/resource/zigbee_connectivity", { + status = 200, + body = { + errors = {}, + data = { { owner = { rid = HUE_DEVICE_ID }, status = "connected" } } + }, + reusable = false + }) + + -- The "connected" status triggers a refresh, so expect the capability emissions + expect_switch_and_level_emit() + + -- Queue expectations for the refresh sequence + queue_initial_light_refresh(rest_conn, true, 80) + + test.wait_for_events() + + assert(mock_devices_api.__is_device_online(mock_bridge.id) == true, + "expected the bridge to be marked online again after the SSE connection reconnected") + assert(mock_devices_api.__is_device_online(mock_light.id) == true, + "expected the light to be marked online when connectivity status reports it as connected") + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_button_lifecycle.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_button_lifecycle.lua new file mode 100644 index 0000000000..fb402765c4 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_button_lifecycle.lua @@ -0,0 +1,42 @@ +--- Test for button device lifecycle (added/init/removed). +--- Tests that lifecycle handlers complete without errors. +--- +--- Note: This test doesn't make HTTP requests during lifecycle operations, +--- so no ConnectionScenario setup is needed. It primarily validates that +--- lifecycle handlers complete without errors. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" + +-- Use proper UUID format for Hue resource IDs +local BUTTON_RID = "aaaaaaaa-bbbb-cccc-dddd-111111111111" +local BUTTON_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-222222222222" +local POWER_RID = "aaaaaaaa-bbbb-cccc-dddd-333333333333" + +-- Single button device fixture WITHOUT SSE (lifecycle only) +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_button(BUTTON_RID, { + battery = 85, + device_id = BUTTON_DEVICE_ID, + power_rid = POWER_RID, + }) + :start() + +local mock_bridge, mock_button = fixtures.bridge, fixtures.devices[1] + +test.set_test_init_function(fixtures.test_init) + +test.register_coroutine_test( + "Button device lifecycle completes successfully", + function() + -- The test passing means: + -- 1. Button lifecycle handlers (added/init) ran without errors + -- 2. supportedButtonValues was emitted as expected + -- 3. Device fields were set correctly + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_button_sse.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_button_sse.lua new file mode 100644 index 0000000000..a17e2f3cc5 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_button_sse.lua @@ -0,0 +1,110 @@ +--- Test for Hue button device with SSE events. +--- Uses HueDeviceBuilder and ConnectionScenario 2.0 with device-type-specific helper. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" + +-- Test constants +local BUTTON_RID = "aaaaaaaa-bbbb-cccc-dddd-111111111111" +local BUTTON_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-222222222222" +local POWER_RID = "aaaaaaaa-bbbb-cccc-dddd-333333333333" +local ZIGBEE_RID = "aaaaaaaa-bbbb-cccc-dddd-444444444444" + +-- Create test fixture using HueDeviceBuilder +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_button(BUTTON_RID, { + num_buttons = 1, + battery = 85, + label = "Hue Button", + device_id = BUTTON_DEVICE_ID, + power_rid = POWER_RID + }) + :enable_sse() + :start() + +local mock_bridge, mock_button = fixtures.bridge, fixtures.devices[1] + +-- Create connection scenario with REST and SSE connections +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- Setup all button init expectations using device-specific helper +-- Override zigbee_rid since the builder uses a default pattern +local button_config = fixtures.configs.button[1] +button_config.zigbee_rid = ZIGBEE_RID +hue_test_helpers.setup_button_init_expectations(rest, sse, button_config) + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +test.register_coroutine_test( + "SSE connection establishes successfully for button device", + function() + -- If we got here without errors, SSE connection was established + test.wait_for_events() + + -- Verify connections are available + local rest_conn = scenario:get_connection("rest") + local sse_conn = scenario:get_connection("sse") + assert(rest_conn ~= nil, "REST connection should be available") + assert(sse_conn ~= nil, "SSE connection should be available") + end +) + +test.register_coroutine_test( + "SSE short_release event emits pushed button event", + function() + test.socket.capability:__expect_send( + mock_button:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + ) + + -- Send SSE event for short_release using helper + local sse_conn = scenario:get_connection("sse") + http.queue_sse_event(sse_conn, { hue_test_helpers.button_event(BUTTON_RID, "short_release") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE long_press event emits held button event", + function() + test.socket.capability:__expect_send( + mock_button:generate_test_message("main", capabilities.button.button.held({ state_change = true })) + ) + + -- Send SSE event for long_press using helper + local sse_conn = scenario:get_connection("sse") + http.queue_sse_event(sse_conn, { hue_test_helpers.button_event(BUTTON_RID, "long_press") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE button event with battery level emits battery event", + function() + -- Use relaxed ordering since battery and button events can arrive in any order + test.socket.capability:__set_channel_ordering("relaxed") + + test.socket.capability:__expect_send( + mock_button:generate_test_message("main", capabilities.battery.battery(42)) + ) + test.socket.capability:__expect_send( + mock_button:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + ) + + -- Send SSE event with both button and battery data using helper + local sse_conn = scenario:get_connection("sse") + http.queue_sse_event(sse_conn, { hue_test_helpers.button_event(BUTTON_RID, "short_release", { + battery_level = 42 + }) }) + + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_child_device_lifecycle.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_child_device_lifecycle.lua new file mode 100644 index 0000000000..db44eb3f62 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_child_device_lifecycle.lua @@ -0,0 +1,64 @@ +--- Test for child device lifecycle with uncached devices (stray device handling). +--- Tests that uncached child devices are properly marked as "stray" rather than +--- attempting to fetch their state via REST (which would fail/crash). +--- +--- TODO: Expand test coverage to explicitly verify: +--- - Stray device field is set correctly on the uncached device +--- - No REST calls are made (could use ConnectionScenario with strict expectations) +--- - Appropriate log messages or warnings are emitted for stray devices +--- - Behavior when attempting to send commands to stray devices + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" +local hue_test_helpers = require "test.hue_test_helpers" + +-- A throwaway light used only to satisfy build_paired_bridge_and_light's fixture requirements +-- (it needs at least one light to seed the disco cache for). The device under test in this +-- file is mock_new_light below, which is deliberately *not* in the disco cache. +local THROWAWAY_LIGHT_RID = "33333333-3333-3333-3333-333333333333" +local NEW_LIGHT_RID = "44444444-4444-4444-4444-444444444444" + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(THROWAWAY_LIGHT_RID, { + on = { on = true } + }, "white-and-color-ambiance.yml") + :start() + +local mock_bridge, mock_throwaway_light = fixtures.bridge, fixtures.devices[1] + +local mock_new_light = test.mock_device.build_test_lan_device({ + label = "New Hue Light", + profile = t_utils.get_profile_definition("white-and-color-ambiance.yml"), + parent_assigned_child_key = "light:" .. NEW_LIGHT_RID, + parent_device_id = mock_bridge.id, +}) + +local function test_init() + fixtures.test_init() + test.mock_device.add_test_device(mock_new_light) + -- LightLifecycleHandlers.init unconditionally emits a levelRange event, regardless of + -- whether the device's resource state is cached -- this has to be registered here (not in a + -- test body) because the automatic device_lifecycle "init" delivery is fully processed + -- before a test's coroutine ever gets its first turn. + hue_test_helpers.expect_light_init_events(mock_new_light) +end + +test.set_test_init_function(test_init) + +test.register_coroutine_test( + "a child device added with no cached resource state is marked as a stray device rather than crashing", + function() + -- No REST call should be made for either light: the throwaway light's resource state is + -- already cached (so light.lua's added handler skips the REST fetch it would otherwise + -- need), and the new light never reaches light.lua's added handler at all -- + -- LifecycleHandlers.device_added checks disco's device_state_disco_cache *before* calling + -- into it, so an uncached light is routed to StrayDeviceHelper instead. Both lights' + -- levelRange emits (asserted via test_init, since that's where the expectations had to be + -- registered) are the only thing expected to happen here. + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_contact_sensor_sse.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_contact_sensor_sse.lua new file mode 100644 index 0000000000..ea92f63a02 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_contact_sensor_sse.lua @@ -0,0 +1,123 @@ +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local Fields = require "fields" + +local http = require "integration_test.connection_scenario_http" + +-- Use proper UUID format for Hue resource IDs +local CONTACT_RID = "ffffffff-1111-1111-1111-111111111111" +local TAMPER_RID = "ffffffff-2222-2222-2222-222222222222" +local POWER_RID = "ffffffff-3333-3333-3333-333333333333" +local CONTACT_DEVICE_ID = "gggggggg-gggg-gggg-gggg-gggggggggggg" +local ZIGBEE_RID = "zigbee-rid-1" + +-- Contact sensor fixture WITH SSE enabled +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_contact(CONTACT_RID, { + battery = 90, + contact_state = "contact", -- "contact" = closed + tamper = "not_tampered", + device_id = CONTACT_DEVICE_ID, + power_rid = POWER_RID, + tamper_rid = TAMPER_RID, + }) + :enable_sse() + :start() + +local mock_bridge, mock_sensor = fixtures.bridge, fixtures.devices[1] + +-- Set up ConnectionScenario for this host:port +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- Setup all contact sensor init expectations using device-specific helper +local contact_config = fixtures.configs.contact[1] +contact_config.zigbee_rid = ZIGBEE_RID +hue_test_helpers.setup_contact_init_expectations(rest, sse, contact_config) + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +test.register_coroutine_test( + "SSE connection establishes successfully for contact sensor", + function() + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE contact open event", + function() + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) + ) + + http.queue_sse_event(sse, { hue_test_helpers.contact_event(CONTACT_RID, "no_contact") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE contact closed event", + function() + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.contactSensor.contact.closed()) + ) + + http.queue_sse_event(sse, { hue_test_helpers.contact_event(CONTACT_RID, "contact") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE tamper detected event", + function() + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.detected()) + ) + + http.queue_sse_event(sse, { hue_test_helpers.tamper_event(TAMPER_RID, "tampered") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE tamper clear event", + function() + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.tamperAlert.tamper.clear()) + ) + + http.queue_sse_event(sse, { hue_test_helpers.tamper_event(TAMPER_RID, "not_tampered") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE combined update with battery", + function() + -- Use relaxed ordering since multiple attributes can arrive in any order + test.socket.capability:__set_channel_ordering("relaxed") + + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.contactSensor.contact.open()) + ) + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.battery.battery(45)) + ) + + http.queue_sse_event(sse, { hue_test_helpers.contact_event(CONTACT_RID, "no_contact", { + battery_level = 45 + }) }) + + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua new file mode 100644 index 0000000000..87a3b50c90 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua @@ -0,0 +1,210 @@ +--- Tests for online/offline status: per-light Zigbee connectivity reported over SSE, and losing the +--- bridge's event stream. Built from payloads recorded on real hardware (see +--- test/fixtures/capture_payloads.lua). + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local mock_devices_api = require "integration_test.mock_devices_api" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local capture_payloads = require "test.fixtures.capture_payloads" + +local LAMP_RID = capture_payloads.ids.color_lamp_light +local LAMP_DEVICE_ID = capture_payloads.ids.color_lamp_device + +local OTHER_RID = "33333333-3333-3333-3333-333333333333" +local OTHER_DEVICE_ID = "other-light-device" +local OTHER_ZIGBEE_RID = "other-light-zigbee" + +local function lamp_light_resource() + local resp = capture_payloads.get("color_lamp_light") + return resp +end + +local lamp_repr = lamp_light_resource().data[1] + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LAMP_RID, { + on = lamp_repr.on, + dimming = lamp_repr.dimming, + color = lamp_repr.color, + color_temperature = lamp_repr.color_temperature, + hue_device_id = LAMP_DEVICE_ID, + label = lamp_repr.metadata.name, + }, "white-and-color-ambiance.yml") + :with_light(OTHER_RID, { on = { on = true }, dimming = { brightness = 80 }, hue_device_id = OTHER_DEVICE_ID }, + "white.yml") + :enable_sse() + :start() + +local mock_bridge = fixtures.bridge +local mock_lamp, mock_other = fixtures.devices[1], fixtures.devices[2] + +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- The events emitted for the recorded lamp's light resource on a refresh. The colour temperature +-- range is only emitted when it changes, i.e. on the first refresh. +local function expect_lamp_state_events(first_refresh) + if first_refresh then + test.socket.capability:__expect_send(mock_lamp:generate_test_message("main", + capabilities.colorTemperature.colorTemperatureRange({ minimum = 2000, maximum = 6500 }))) + end + for _, event in ipairs({ + capabilities["samsungim.hueSyncMode"].mode("normal"), + capabilities.switch.switch.on(), + capabilities.switchLevel.level(100), + capabilities.colorTemperature.colorTemperature(2732), + capabilities.colorControl.hue(11), + capabilities.colorControl.saturation(53), + }) do + test.socket.capability:__expect_send(mock_lamp:generate_test_message("main", event)) + end +end + +local function expect_other_state_events() + test.socket.capability:__expect_send(mock_other:generate_test_message("main", capabilities.switch.switch.on())) + test.socket.capability:__expect_send(mock_other:generate_test_message("main", capabilities.switchLevel.level(80))) +end + +-- Recorded REST responses for the lamp, and generic ones for the other light; reusable so they +-- serve every refresh. +http.expect_request(rest, "GET", "/clip/v2/resource/device/" .. LAMP_DEVICE_ID, { + status = 200, body = capture_payloads.get("color_lamp_device"), reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity/" .. capture_payloads.ids.color_lamp_zigbee, { + status = 200, body = capture_payloads.get("color_lamp_zigbee"), reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. LAMP_RID, { + status = 200, body = lamp_light_resource(), reusable = true, +}) +hue_test_helpers.expect_device_info(rest, OTHER_DEVICE_ID, + { { rtype = "zigbee_connectivity", rid = OTHER_ZIGBEE_RID } }, { reusable = true }) +hue_test_helpers.expect_zigbee_connectivity(rest, OTHER_ZIGBEE_RID, { owner = OTHER_DEVICE_ID, reusable = true }) +hue_test_helpers.expect_light_resource(rest, OTHER_RID, true, 80, { reusable = true }) + +test.set_test_init_function(function() + http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + scenario:activate() + -- Connectivity poll after the event stream opens. + http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true, + persistent = false, + }) + + test.socket.capability:__set_channel_ordering("relaxed") + expect_lamp_state_events(true) -- .added's injected refresh + expect_other_state_events() + fixtures.test_init() + expect_lamp_state_events(false) -- .init's injected refresh + expect_other_state_events() +end) + +--- Let the driver run until `predicate` holds (online/offline changes emit no capability events +--- for `wait_for_events` to wait on). +local function wait_until(predicate) + test.wait_until(predicate, { message = "the expected online/offline state" }) +end + +local function is_online(mock_device) + return mock_devices_api.__is_device_online(mock_device.id) +end + +local function assert_online(mock_device, expected, what) + assert(mock_devices_api.__is_device_online(mock_device.id) == expected, + string.format("expected %s to be %s", what, expected and "online" or "offline")) +end + +--- Assert how many times the bridge and each light were marked offline since the history was +--- last cleared. +local function assert_offline_calls(expected) + for _, entry in ipairs({ { mock_bridge, "bridge" }, { mock_lamp, "lamp" }, { mock_other, "other light" } }) do + local count = mock_devices_api.__count_device_status_calls(entry[1].id, false) + assert(count == expected, + string.format("expected the %s to be marked offline %d time(s), got %d", entry[2], expected, count)) + end +end + +test.register_coroutine_test( + "a Zigbee connectivity issue for one light marks only that light offline", + function() + test.wait_for_events() + assert_online(mock_lamp, true, "the lamp") + + http.queue_sse_event(scenario:get_connection("sse"), capture_payloads.get("color_lamp_connectivity_issue")) + wait_until(function() return is_online(mock_lamp) == false end) + + assert_online(mock_lamp, false, "the lamp") + assert_online(mock_other, true, "the other light") + assert_online(mock_bridge, true, "the bridge") + end +) + +test.register_coroutine_test( + "a light whose Zigbee connection comes back is marked online and refreshed", + function() + test.wait_for_events() + local sse_conn = scenario:get_connection("sse") + http.queue_sse_event(sse_conn, capture_payloads.get("color_lamp_connectivity_issue")) + wait_until(function() return is_online(mock_lamp) == false end) + assert_online(mock_lamp, false, "the lamp") + + http.queue_sse_event(sse_conn, capture_payloads.get("color_lamp_connected")) + expect_lamp_state_events(false) + test.wait_for_events() + + assert_online(mock_lamp, true, "the lamp") + end +) + +test.register_coroutine_test( + "while the event stream keeps failing to reconnect, devices are only marked offline once", + function() + test.wait_for_events() + assert_online(mock_bridge, true, "the bridge") + + mock_devices_api.__clear_device_status_history() + -- The bridge drops the event stream and is unreachable for the next two reconnect attempts, + -- as in physical test scenarios 8 and 9 ("No route to host" every few seconds). + scenario:fail_connects(2, "No route to host") + scenario:get_connection("sse"):close_connection() + wait_until(function() return is_online(mock_bridge) == false end) + wait_until(function() return is_online(mock_bridge) == true end) + assert_offline_calls(1) + assert_online(mock_bridge, true, "the bridge") + assert(mock_devices_api.__count_device_status_calls(mock_bridge.id, true) == 1, + "expected the bridge to be marked online once, when the stream reconnected") + + -- Once the stream has reconnected, a later outage is reported again. + scenario:get_connection("sse"):close_connection() + wait_until(function() return is_online(mock_bridge) == false end) + assert_offline_calls(2) + wait_until(function() return is_online(mock_bridge) == true end) + + end +) + +test.register_coroutine_test( + "while the bridge rejects event stream reconnects, devices are only marked offline once", + function() + test.wait_for_events() + mock_devices_api.__clear_device_status_history() + + -- The bridge drops the stream and answers the next two reconnects with 503 (e.g. while it + -- restarts), ahead of the reusable handshake registered in test_init. + http.expect_request_for_test(sse, "GET", "/eventstream/clip/v2", { status = 503, times = 2, prepend = true }) + scenario:get_connection("sse"):close_connection() + wait_until(function() return is_online(mock_bridge) == false end) + wait_until(function() return is_online(mock_bridge) == true end) + + assert_offline_calls(1) + assert_online(mock_bridge, true, "the bridge") + local _, handshakes = scenario:get_connection("sse"):get_sent_log():gsub("GET /eventstream/clip/v2", "") + assert(handshakes == 4, string.format("expected the initial connect plus three reconnects, got %d", handshakes)) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua new file mode 100644 index 0000000000..d25e07337e --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua @@ -0,0 +1,116 @@ +--- Tests for a driver restart with already-onboarded devices (physical test scenario 2): each device +--- only receives `init`, with the fields an earlier `added` persisted, and is brought up to date by +--- the refresh that follows the event stream's connectivity check rather than by re-onboarding. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local mock_devices_api = require "integration_test.mock_devices_api" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local capture_payloads = require "test.fixtures.capture_payloads" +local Fields = require "fields" +local HueDeviceTypes = require "hue_device_types" + +local ids = capture_payloads.ids + +local function lamp_light_resource() + local resp = capture_payloads.get("color_lamp_light") + return resp +end + +local lamp_repr = lamp_light_resource().data[1] + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(ids.color_lamp_light, { + on = lamp_repr.on, + dimming = lamp_repr.dimming, + color = lamp_repr.color, + color_temperature = lamp_repr.color_temperature, + hue_device_id = ids.color_lamp_device, + label = lamp_repr.metadata.name, + }, "white-and-color-ambiance.yml") + :enable_sse() + :start() + +local mock_bridge, mock_lamp = fixtures.bridge, fixtures.devices[1] + +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +http.expect_request(rest, "GET", "/clip/v2/resource/device/" .. ids.color_lamp_device, { + status = 200, body = capture_payloads.get("color_lamp_device"), reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity/" .. ids.color_lamp_zigbee, { + status = 200, body = capture_payloads.get("color_lamp_zigbee"), reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. ids.color_lamp_light, { + status = 200, body = lamp_light_resource(), reusable = true, +}) + +-- The fields the earlier `added` (and first `init`) left persisted. +local function seed_persisted_fields() + mock_bridge:set_field(Fields._ADDED, true, { persist = true }) + mock_lamp:set_field(Fields.DEVICE_TYPE, HueDeviceTypes.LIGHT, { persist = true }) + mock_lamp:set_field(Fields.RESOURCE_ID, ids.color_lamp_light, { persist = true }) + mock_lamp:set_field(Fields.HUE_DEVICE_ID, ids.color_lamp_device, { persist = true }) + mock_lamp:set_field(Fields.PARENT_DEVICE_ID, mock_bridge.id, { persist = true }) + mock_lamp:set_field(Fields.GAMUT, lamp_repr.color.gamut, { persist = true }) + mock_lamp:set_field(Fields.MIN_KELVIN, 2000, { persist = true }) + mock_lamp:set_field(Fields.MAX_KELVIN, 6500, { persist = true }) + mock_lamp:set_field(Fields._ADDED, true, { persist = true }) + mock_lamp:set_field(Fields._REFRESH_AFTER_INIT, false, { persist = true }) +end + +test.set_test_init_function(function() + http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + scenario:activate() + -- The connectivity check after the event stream opens reports the lamp connected. + http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + status = 200, + body = { errors = {}, data = { { + id = ids.color_lamp_zigbee, + owner = { rid = ids.color_lamp_device, rtype = "device" }, + status = "connected", + type = "zigbee_connectivity", + } } }, + reusable = false, + persistent = false, + }) + + fixtures.test_init() -- adds the devices; registers the lamp's init levelRange emit + seed_persisted_fields() + + -- The refresh injected once the connectivity check reports the lamp connected. + test.socket.capability:__set_channel_ordering("relaxed") + for _, event in ipairs({ + capabilities.colorTemperature.colorTemperatureRange({ minimum = 2000, maximum = 6500 }), + capabilities["samsungim.hueSyncMode"].mode("normal"), + capabilities.switch.switch.on(), + capabilities.switchLevel.level(100), + capabilities.colorTemperature.colorTemperature(2732), + capabilities.colorControl.hue(11), + capabilities.colorControl.saturation(53), + }) do + test.socket.capability:__expect_send(mock_lamp:generate_test_message("main", event)) + end +end) + +test.register_coroutine_test( + "after a restart an onboarded light is initialized and refreshed once, without being re-added", + function() + test.wait_until(function() return mock_devices_api.__is_device_online(mock_lamp.id) end, + { message = "the lamp coming online" }) + test.wait_for_events() + + assert(mock_devices_api.__is_device_online(mock_bridge.id) == true, "expected the bridge to be online") + assert(mock_devices_api.__is_device_online(mock_lamp.id) == true, "expected the lamp to be online") + + -- One refresh: the device, connectivity and light GETs happen once each. + local sent = rest:get_sent_log() + local _, light_gets = sent:gsub("GET /clip/v2/resource/light/" .. hue_test_helpers.escape_uuid(ids.color_lamp_light), "") + assert(light_gets == 1, string.format("expected 1 light refresh, got %d", light_gets)) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua new file mode 100644 index 0000000000..879e28f13d --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua @@ -0,0 +1,315 @@ +--- Test for error handling in Hue light commands and refresh operations. +--- Uses ConnectionScenario 2.0 for testing various error scenarios. +--- +--- Tests various error scenarios: 404, 500, API errors, timeouts, malformed JSON, etc. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local connection_scenario = require "integration_test.connection_scenario" +local http = require "integration_test.connection_scenario_http" +local Fields = require "fields" + +local LIGHT_RID = "11111111-1111-1111-1111-111111111111" +local LIGHT_DEVICE_ID = "22222222-2222-2222-2222-222222222222" + +-- Standard light fixture (no SSE for command tests) +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LIGHT_RID, { + on = { on = true }, + dimming = { brightness = 100 }, + hue_device_id = LIGHT_DEVICE_ID, + }, "white-and-color-ambiance.yml") + :start() + +local mock_bridge, mock_light = fixtures.bridge, fixtures.devices[1] + +-- Set up ConnectionScenario for error handling testing +-- This test file needs both GET and PUT connections simultaneously for different test scenarios +local scenario = connection_scenario.new({ host = hue_test_helpers.BRIDGE_IP, port = 443 }) + +-- Define PUT connection for light commands +local put_conn = scenario:connection("put", { + matcher = http.matcher("PUT", "/clip/v2/resource/"), + ordering = "relaxed" +}) + +-- Define GET connection for refresh operations +local get_conn = scenario:connection("get", { + matcher = http.matcher("GET", "/clip/v2/resource/"), + ordering = "relaxed" +}) + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +test.register_coroutine_test( + "Light command handles 404 error gracefully", + function() + -- Test-specific expectation: 404 error response + http.expect_request_for_test(put_conn, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 404, + body = { + errors = { + { + type = "resource_not_found", + description = "Resource not found" + } + } + } + }) + + -- Send switch on command + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + + test.wait_for_events() + + -- Verify PUT request was sent + put_conn:assert_sent("PUT /clip/v2/resource/light/11111111%-1111%-1111%-1111%-111111111111") + + -- Device should not emit any state change events on error + -- (test passes if no unexpected capability events were sent) + end +) + +test.register_coroutine_test( + "Light command handles 500 internal server error", + function() + http.expect_request_for_test(put_conn, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 500, + body = { + errors = { + { + type = "internal_error", + description = "Internal server error" + } + } + } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switchLevel", component = "main", command = "setLevel", args = { 50 } }, + }) + + test.wait_for_events() + + -- Verify PUT request was sent + put_conn:assert_sent("PUT /clip/v2/resource/light/11111111%-1111%-1111%-1111%-111111111111") + + -- Device should not emit state change on error + end +) + +test.register_coroutine_test( + "Light command handles Hue API error in response body", + function() + http.expect_request_for_test(put_conn, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { + errors = { + { + description = "Light is unreachable" + } + }, + data = {} + } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switch", component = "main", command = "off", args = {} }, + }) + + test.wait_for_events() + put_conn:assert_sent("PUT /clip/v2/resource/light/11111111%-1111%-1111%-1111%-111111111111") + + -- Command should handle error gracefully (no crash, logs error) + end +) + +-- TODO: Re-enable timeout test with proper connection closing mechanism +-- The current ConnectionScenario framework doesn't have an easy way to simulate +-- an immediate connection close/timeout without actually waiting for the timeout to occur. +-- Need to either: +-- 1. Add a mechanism to immediately fail/close a connection after it's established +-- 2. Reduce the socket timeout for testing +-- 3. Use the old mock server's close_connection() approach +--[[ +test.register_coroutine_test( + "Light command handles connection timeout", + function() + -- Don't define expectation - let the connection timeout naturally + -- by not having any response ready + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + + test.wait_for_events() + + -- Driver should handle timeout gracefully without crashing + -- (timeout will occur because no expectation matched, so no response generated) + end +) +--]] + + +test.register_coroutine_test( + "Refresh handles missing zigbee connectivity gracefully", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + + -- Return device info without zigbee_connectivity service + http.expect_request_for_test(get_conn, "GET", "/clip/v2/resource/device/" .. LIGHT_DEVICE_ID, { + status = 200, + body = { + errors = {}, + data = { + { + id = LIGHT_DEVICE_ID, + services = { + { rtype = "light", rid = LIGHT_RID } + -- Note: no zigbee_connectivity service + } + } + } + } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + + test.wait_for_events() + get_conn:assert_sent("GET /clip/v2/resource/device/22222222%-2222%-2222%-2222%-222222222222") + + -- Driver logs error about missing zigbee_connectivity and returns early + -- (no light state fetch attempted, which is the correct behavior) + -- Test passes if driver handles this gracefully without crashing + end +) + +test.register_coroutine_test( + "Refresh handles 404 for deleted device", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + + -- Device info lookup returns 404 (device deleted on bridge) + http.expect_request_for_test(get_conn, "GET", "/clip/v2/resource/device/" .. LIGHT_DEVICE_ID, { + status = 404, + body = { + errors = { + { + type = "resource_not_found", + description = "Device not found" + } + } + } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + + test.wait_for_events() + get_conn:assert_sent("GET /clip/v2/resource/device/22222222%-2222%-2222%-2222%-222222222222") + + -- Should handle gracefully (logs error, doesn't crash) + end +) + +test.register_coroutine_test( + "Malformed JSON response handled gracefully", + function() + -- Use connection:expect_for_test directly with raw response data + put_conn:expect_for_test({ + request = { pattern = "^PUT /clip/v2/resource/light/11111111%-1111%-1111%-1111%-111111111111" }, + responses = { + { data = http.format_response(200, {["content-type"] = "application/json"}, "{ invalid json") } + } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + + test.wait_for_events() + put_conn:assert_sent("PUT /clip/v2/resource/light/11111111%-1111%-1111%-1111%-111111111111") + + -- Driver should handle parse error without crashing + end +) + +test.register_coroutine_test( + "Empty response body handled gracefully", + function() + -- Use connection:expect_for_test directly with empty body + put_conn:expect_for_test({ + request = { pattern = "^PUT /clip/v2/resource/light/11111111%-1111%-1111%-1111%-111111111111" }, + responses = { + { data = http.format_response(200, {["content-type"] = "application/json"}, "") } + } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switchLevel", component = "main", command = "setLevel", args = { 25 } }, + }) + + test.wait_for_events() + put_conn:assert_sent("PUT /clip/v2/resource/light/11111111%-1111%-1111%-1111%-111111111111") + + -- Driver should handle empty response without crashing + end +) + +test.register_coroutine_test( + "a command on a keep-alive connection the bridge has since closed is resent on a new connection", + function() + -- Physical test scenario 6: after ~90 idle minutes the first PUT went out on a connection the + -- bridge had already closed; the driver reconnected and resent it, and the light turned on. + local function expect_put() + http.expect_request_for_test(put_conn, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } }, errors = {} }, + }) + end + local function switch_on() + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + test.wait_for_events() + end + + expect_put() + switch_on() + + put_conn:close_connection() + expect_put() + switch_on() + + -- The second command went out on the closed connection, then again on a new one after the + -- REST client's retry backoff. + local path = "/clip/v2/resource/light/" .. LIGHT_RID + test.wait_until(function() return http.count_requests(put_conn, "PUT", path) >= 3 end, + { message = "the command being resent" }) + local requests = http.find_requests(put_conn, "PUT", path) + assert(#requests == 3, string.format("expected 3 PUTs, got %d", #requests)) + assert(requests[2].connection_index == requests[1].connection_index, + "expected the second command to go out first on the connection the bridge had closed") + assert(requests[3].connection_index == requests[1].connection_index + 1, + "expected the second command to be resent on a new connection") + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua new file mode 100644 index 0000000000..a230e341f7 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua @@ -0,0 +1,136 @@ +--- Tests for colour state reported by the bridge, built from payloads recorded on real hardware +--- (see test/fixtures/capture_payloads.lua). + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local capture_payloads = require "test.fixtures.capture_payloads" + +local LIGHT_RID = capture_payloads.ids.color_lamp_2_light +local ZIGBEE_RID = "zigbee-color-lamp-2" + +-- Hue gamut A (e.g. LCT001/LLC010). The recorded lamp is gamut C, which is also the driver's +-- default gamut, so the recorded light resource is given gamut A here to make a fallback to the +-- default gamut observable. +local GAMUT_A = { + red = { x = 0.704, y = 0.296 }, + green = { x = 0.2151, y = 0.7106 }, + blue = { x = 0.138, y = 0.08 }, +} + +local function light_resource() + local resp = capture_payloads.get("color_lamp_2_light") + resp.data[1].color.gamut = GAMUT_A + resp.data[1].color.gamut_type = "A" + return resp +end + +local light_repr = light_resource().data[1] +local HUE_DEVICE_ID = light_repr.owner.rid + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LIGHT_RID, { + on = light_repr.on, + dimming = light_repr.dimming, + color = light_repr.color, + color_temperature = light_repr.color_temperature, + hue_device_id = HUE_DEVICE_ID, + label = light_repr.metadata.name, + }, "white-and-color-ambiance.yml") + :enable_sse() + :start() + +local mock_light = fixtures.devices[1] + +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- The events emitted for the recorded light resource on a refresh. The colour temperature range +-- is only emitted when it changes, i.e. on the first refresh. +local function expect_light_state_events(first_refresh) + if first_refresh then + test.socket.capability:__expect_send(mock_light:generate_test_message("main", + capabilities.colorTemperature.colorTemperatureRange({ minimum = 2000, maximum = 6500 }))) + end + for _, event in ipairs({ + capabilities["samsungim.hueSyncMode"].mode("normal"), + capabilities.switch.switch.on(), + capabilities.switchLevel.level(100), + capabilities.colorTemperature.colorTemperature(2710), + capabilities.colorControl.hue(11), + capabilities.colorControl.saturation(54), + }) do + test.socket.capability:__expect_send(mock_light:generate_test_message("main", event)) + end +end + +local function queue_light_refresh() + hue_test_helpers.expect_device_info(rest, HUE_DEVICE_ID, + { { rtype = "zigbee_connectivity", rid = ZIGBEE_RID } }, { reusable = false }) + hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID, { owner = HUE_DEVICE_ID, reusable = false }) + http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = light_resource(), + }) +end + +test.set_test_init_function(function() + queue_light_refresh() -- .added's injected refresh + queue_light_refresh() -- .init's injected refresh + http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + scenario:activate() + http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true, + persistent = false, + }) + + test.socket.capability:__set_channel_ordering("relaxed") + expect_light_state_events(true) + fixtures.test_init() + expect_light_state_events() +end) + +test.register_coroutine_test( + "a colour update with no valid mirek emits hue and saturation but no colour temperature", + function() + test.wait_for_events() + + -- Recorded after a setColor to red: the bridge reports `mirek: null, mirek_valid: false`. + http.queue_sse_event(scenario:get_connection("sse"), capture_payloads.get("color_lamp_2_color_mode")) + test.socket.capability:__expect_send(mock_light:generate_test_message("main", capabilities.colorControl.hue(2))) + test.socket.capability:__expect_send(mock_light:generate_test_message("main", capabilities.colorControl.saturation(99))) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "a colour update without a gamut keeps the light's gamut for later setColor commands", + function() + test.wait_for_events() + + http.queue_sse_event(scenario:get_connection("sse"), capture_payloads.get("color_lamp_2_color_mode")) + test.socket.capability:__expect_send(mock_light:generate_test_message("main", capabilities.colorControl.hue(2))) + test.socket.capability:__expect_send(mock_light:generate_test_message("main", capabilities.colorControl.saturation(99))) + test.wait_for_events() + + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } }, errors = {} }, + }) + -- Pure green: clamped to gamut A's green corner, not the default (gamut C) corner at x = 0.17. + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "colorControl", component = "main", command = "setColor", + args = { { hue = 100 / 3, saturation = 100 } } }, + }) + test.wait_for_events() + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, + { color = { xy = { x = 0.2151, y = 0.7106 } } }) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua new file mode 100644 index 0000000000..ebe4e12072 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua @@ -0,0 +1,280 @@ +--- Test for Hue light command handling (switch, level, color, temperature). +--- Uses ConnectionScenario 2.0 for PUT command testing. + +local test = require "integration_test" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" + +local LIGHT_RID = "11111111-1111-1111-1111-111111111111" + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LIGHT_RID, { + on = { on = true }, + dimming = { brightness = 100 }, + color = { xy = { x = 0.3, y = 0.3 }, gamut = { red = { x = 0.7, y = 0.3 }, green = { x = 0.2, y = 0.7 }, blue = { x = 0.15, y = 0.05 } } }, + color_temperature = { mirek = 366, mirek_schema = { mirek_minimum = 153, mirek_maximum = 500 } }, + mode = "normal", + }, "white-and-color-ambiance.yml") + :start() + +local mock_bridge, mock_light = fixtures.bridge, fixtures.devices[1] + +-- Set up ConnectionScenario for PUT command testing +local scenario, conns = hue_test_helpers.create_hue_scenario({ + rest = true, + rest_method = "PUT" -- Override default GET to PUT for commands +}) +local rest = conns.rest + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +-- NOTE: Profile compatibility is implicitly tested here. The white-and-color-ambiance profile +-- supports all capabilities: switch, switchLevel, colorControl, and colorTemperature. +-- Tests for profile-restricted lights (white-only, white-ambiance) are in other test files +-- where those specific profiles make sense in context (e.g., test_hue_light_refresh.lua). + +test.register_coroutine_test( + "switch on command sends a PUT to turn the light on", + function() + -- Test-specific expectation: PUT command with OK response + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } } } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switch", component = "main", command = "on", args = {} }, + }) + test.wait_for_events() + + -- Verify the request body contains the expected on=true + rest:assert_sent('"on":%s*{%s*"on":%s*true%s*}') + end +) + +test.register_coroutine_test( + "switch off command sends a PUT to turn the light off", + function() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } } } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switch", component = "main", command = "off", args = {} }, + }) + test.wait_for_events() + + -- Verify the request body contains the expected on=false + rest:assert_sent('"on":%s*{%s*"on":%s*false%s*}') + end +) + +test.register_coroutine_test( + "setLevel command sends a PUT with the requested brightness", + function() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } } } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switchLevel", component = "main", command = "setLevel", args = { 42 } }, + }) + test.wait_for_events() + + -- Verify the request body contains the expected brightness + rest:assert_sent('"dimming":%s*{%s*"brightness":%s*42') + end +) + +test.register_coroutine_test( + "setColorTemperature command sends a PUT with the mirek conversion of the requested Kelvin value", + function() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } } } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = { 3000 } }, + }) + test.wait_for_events() + + -- Verify the request body contains the expected mirek value (3000K = 333 mirek) + rest:assert_sent('"color_temperature":%s*{%s*"mirek":%s*333') + rest:assert_sent('"on":%s*{%s*"on":%s*true%s*}') + end +) + +test.register_coroutine_test( + "setColor command converts HSV to XY and sends PUT with color", + function() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } } } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "colorControl", component = "main", command = "setColor", args = { { hue = 0, saturation = 100 } } }, + }) + test.wait_for_events() + + -- Extract and parse the JSON body from the request + local sent = rest:get_sent_log() + local body_json = sent:match("\r\n\r\n(.+)") + assert(body_json, "Expected to find request body") + + local dkjson = require("dkjson") + local body = dkjson.decode(body_json) + + -- Verify the request body has the expected structure + assert(body.color ~= nil, "Expected color in body") + assert(body.color.xy ~= nil, "Expected color.xy in body") + assert(type(body.color.xy.x) == "number", "Expected color.xy.x to be a number") + assert(type(body.color.xy.y) == "number", "Expected color.xy.y to be a number") + assert(body.on ~= nil and body.on.on == true, "Expected light to be turned on") + + -- Validate specific XY values for red (hue=0, saturation=100) + -- Expected: x=0.7, y=0.3 (gamut red point) + local tolerance = 0.01 + assert(math.abs(body.color.xy.x - 0.7) < tolerance, + string.format("Expected x≈0.7 but got %.6f", body.color.xy.x)) + assert(math.abs(body.color.xy.y - 0.3) < tolerance, + string.format("Expected y≈0.3 but got %.6f", body.color.xy.y)) + end +) + +test.register_coroutine_test( + "setHue command uses existing saturation and sends PUT with color", + function() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } } } + }) + + -- Set initial saturation field + mock_light:set_field("_color_saturation", 50) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "colorControl", component = "main", command = "setHue", args = { 240 } }, -- Blue hue + }) + test.wait_for_events() + + -- Extract and parse the JSON body from the request + local sent = rest:get_sent_log() + local body_json = sent:match("\r\n\r\n(.+)") + assert(body_json, "Expected to find request body") + + local dkjson = require("dkjson") + local body = dkjson.decode(body_json) + + -- Verify color.xy structure exists + assert(body.color ~= nil, "Expected color in body") + assert(body.color.xy ~= nil, "Expected color.xy in body") + assert(type(body.color.xy.x) == "number", "Expected color.xy.x to be a number") + assert(type(body.color.xy.y) == "number", "Expected color.xy.y to be a number") + + -- Validate specific XY values for blue hue=240 with saturation=50 + -- Expected: x≈0.323, y≈0.329 + local tolerance = 0.01 + assert(math.abs(body.color.xy.x - 0.323) < tolerance, + string.format("Expected x≈0.323 but got %.6f", body.color.xy.x)) + assert(math.abs(body.color.xy.y - 0.329) < tolerance, + string.format("Expected y≈0.329 but got %.6f", body.color.xy.y)) + end +) + +test.register_coroutine_test( + "setSaturation command uses existing hue and sends PUT with color", + function() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } } } + }) + + -- Set initial hue field + mock_light:set_field("_color_hue", 120) -- Green hue + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "colorControl", component = "main", command = "setSaturation", args = { 75 } }, + }) + test.wait_for_events() + + -- Extract and parse the JSON body from the request + local sent = rest:get_sent_log() + local body_json = sent:match("\r\n\r\n(.+)") + assert(body_json, "Expected to find request body") + + local dkjson = require("dkjson") + local body = dkjson.decode(body_json) + + -- Verify color.xy structure exists + assert(body.color ~= nil, "Expected color in body") + assert(body.color.xy ~= nil, "Expected color.xy in body") + assert(type(body.color.xy.x) == "number", "Expected color.xy.x to be a number") + assert(type(body.color.xy.y) == "number", "Expected color.xy.y to be a number") + + -- Validate specific XY values for green hue=120 with saturation=75 + -- Expected: x≈0.645, y≈0.304 + local tolerance = 0.01 + assert(math.abs(body.color.xy.x - 0.645) < tolerance, + string.format("Expected x≈0.645 but got %.6f", body.color.xy.x)) + assert(math.abs(body.color.xy.y - 0.304) < tolerance, + string.format("Expected y≈0.304 but got %.6f", body.color.xy.y)) + end +) + +-- Out-of-range values sent from the SmartThings API during physical testing (scenario 15), and the +-- request bodies the driver sent the bridge for them. +local function expect_light_put() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } } } + }) +end + +test.register_coroutine_test( + "setLevel 0 is clamped to the minimum brightness of 1", + function() + expect_light_put() + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switchLevel", component = "main", command = "setLevel", args = { 0 } }, + }) + test.wait_for_events() + + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { dimming = { brightness = 1 } }) + end +) + +for _, case in ipairs({ + { kelvin = 100, mirek = 500 }, -- below the light's range: its warmest setting + { kelvin = 10000, mirek = 153 }, -- above the light's range: its coolest setting +}) do + test.register_coroutine_test( + string.format("setColorTemperature %dK is clamped to the light's range (mirek %d)", case.kelvin, case.mirek), + function() + expect_light_put() + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = { case.kelvin } }, + }) + test.wait_for_events() + + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, + { color_temperature = { mirek = case.mirek }, on = { on = true } }) + end + ) +end + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_light_refresh.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_refresh.lua new file mode 100644 index 0000000000..b8951f68f2 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_refresh.lua @@ -0,0 +1,103 @@ +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" + +local LIGHT_RID = "22222222-2222-2222-2222-222222222222" +local HUE_DEVICE_ID = "device-uuid-1" +local ZIGBEE_RID = "zigbee-conn-1" + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LIGHT_RID, { + on = { on = true }, + dimming = { brightness = 80 }, + hue_device_id = HUE_DEVICE_ID, + }, "white-and-color-ambiance.yml") + :start() + +local mock_bridge, mock_light = fixtures.bridge, fixtures.devices[1] + +-- Set up ConnectionScenario for REST-only testing +local scenario, conns = hue_test_helpers.create_hue_scenario() +local rest = conns.rest + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +-- Define refresh sequence expectations +-- During refresh, the driver queries device info (to get zigbee_connectivity RID), +-- then checks zigbee connectivity, then queries light state + +-- 1. GET device info to find zigbee_connectivity resource (reusable across tests) +hue_test_helpers.expect_device_info(rest, HUE_DEVICE_ID, + {{ rtype = "zigbee_connectivity", rid = ZIGBEE_RID }}, + { reusable = true } +) + +-- 2. GET zigbee connectivity status (reusable across tests) +hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID, + { owner = HUE_DEVICE_ID, reusable = true } +) + +-- Note: Light state expectations are test-specific and defined in each test body + +test.register_coroutine_test( + "refresh command reads light state over REST and emits switch/switchLevel events", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + + -- Test-specific expectation: light is on and bright + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { + errors = {}, + data = {{ id = LIGHT_RID, on = { on = true }, dimming = { brightness = 80 } }} + } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switch.switch.on()) + ) + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switchLevel.level(80)) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "refresh command reflects an off/dimmed state from the REST response", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + + -- Test-specific expectation: light is off and dim + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { + errors = {}, + data = {{ id = LIGHT_RID, on = { on = false }, dimming = { brightness = 15 } }} + } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switch.switch.off()) + ) + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switchLevel.level(15)) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_motion_sensor_sse.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_motion_sensor_sse.lua new file mode 100644 index 0000000000..9102bd0739 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_motion_sensor_sse.lua @@ -0,0 +1,131 @@ +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" + +-- Use proper UUID format for Hue resource IDs +local MOTION_RID = "dddddddd-1111-1111-1111-111111111111" +local TEMP_RID = "dddddddd-2222-2222-2222-222222222222" +local LIGHT_RID = "dddddddd-3333-3333-3333-333333333333" +local POWER_RID = "dddddddd-4444-4444-4444-444444444444" +local MOTION_DEVICE_ID = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" +local ZIGBEE_RID = "zigbee-rid-1" + +-- Motion sensor fixture WITH SSE enabled +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_motion(MOTION_RID, { + battery = 95, + motion = false, + temperature = 20.0, + light_level = 30000, -- ~1000 lux + device_id = MOTION_DEVICE_ID, + power_rid = POWER_RID, + temperature_rid = TEMP_RID, + light_level_rid = LIGHT_RID, + }) + :enable_sse() + :start() + +local mock_bridge, mock_sensor = fixtures.bridge, fixtures.devices[1] + +-- Set up ConnectionScenario for this host:port +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- Setup all motion sensor init expectations using device-specific helper +local motion_config = fixtures.configs.motion[1] +motion_config.zigbee_rid = ZIGBEE_RID +hue_test_helpers.setup_motion_init_expectations(rest, sse, motion_config) + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +test.register_coroutine_test( + "SSE connection establishes successfully for motion sensor", + function() + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE motion active event", + function() + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) + ) + + http.queue_sse_event(sse, { hue_test_helpers.motion_event(MOTION_RID, true) }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE motion inactive event", + function() + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.inactive()) + ) + + http.queue_sse_event(sse, { hue_test_helpers.motion_event(MOTION_RID, false) }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE temperature update event", + function() + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 22.5, unit = "C" })) + ) + + http.queue_sse_event(sse, { hue_test_helpers.temperature_event(TEMP_RID, 22.5) }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE illuminance update event", + function() + -- Hue light level formula: 10000*log10(lux) + 1 + -- For ~500 lux: light_level = 27000 → actual lux = round(10^((27000-1)/10000)) = 501 + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.illuminanceMeasurement.illuminance(501)) + ) + + http.queue_sse_event(sse, { hue_test_helpers.light_level_event(LIGHT_RID, 27000) }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE combined update with multiple attributes", + function() + -- Use relaxed ordering since multiple attributes can arrive in any order + test.socket.capability:__set_channel_ordering("relaxed") + + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.motionSensor.motion.active()) + ) + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.battery.battery(50)) + ) + test.socket.capability:__expect_send( + mock_sensor:generate_test_message("main", capabilities.temperatureMeasurement.temperature({ value = 18.0, unit = "C" })) + ) + + -- Motion sensors can send updates with multiple service types + http.queue_sse_event(sse, { hue_test_helpers.motion_event(MOTION_RID, true, { + battery_level = 50, + temperature = 18.0 + }) }) + + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_multibutton_sse.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_multibutton_sse.lua new file mode 100644 index 0000000000..9f91763e44 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_multibutton_sse.lua @@ -0,0 +1,95 @@ +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" + +-- Use proper UUID format for Hue resource IDs (4-button remote) +local BUTTON_RID_1 = "aaaaaaaa-1111-1111-1111-111111111111" +local BUTTON_RID_2 = "aaaaaaaa-2222-2222-2222-222222222222" +local BUTTON_RID_3 = "aaaaaaaa-3333-3333-3333-333333333333" +local BUTTON_RID_4 = "aaaaaaaa-4444-4444-4444-444444444444" +local BUTTON_DEVICE_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +local POWER_RID = "cccccccc-cccc-cccc-cccc-cccccccccccc" +local ZIGBEE_RID = "zigbee-rid-1" + +-- 4-button remote fixture WITH SSE enabled +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_button(BUTTON_RID_1, { + num_buttons = 4, + battery = 90, + device_id = BUTTON_DEVICE_ID, + power_rid = POWER_RID, + button_rids = { BUTTON_RID_1, BUTTON_RID_2, BUTTON_RID_3, BUTTON_RID_4 }, + label = "Hue Dimmer Remote", + }, "4-button-remote.yml") + :enable_sse() + :start() + +local mock_bridge, mock_remote = fixtures.bridge, fixtures.devices[1] + +-- Set up ConnectionScenario for this host:port +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- Setup all button init expectations using device-specific helper +local button_config = fixtures.configs.button[1] +button_config.zigbee_rid = ZIGBEE_RID +hue_test_helpers.setup_button_init_expectations(rest, sse, button_config) + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +test.register_coroutine_test( + "SSE event to button 1 (main component) routes correctly", + function() + test.socket.capability:__expect_send( + mock_remote:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + ) + + http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_1, "short_release") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE event to button 2 routes to button2 component", + function() + test.socket.capability:__expect_send( + mock_remote:generate_test_message("button2", capabilities.button.button.held({ state_change = true })) + ) + + http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_2, "long_press") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE event to button 3 routes to button3 component", + function() + test.socket.capability:__expect_send( + mock_remote:generate_test_message("button3", capabilities.button.button.pushed({ state_change = true })) + ) + + http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_3, "short_release") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE event to button 4 routes to button4 component", + function() + test.socket.capability:__expect_send( + mock_remote:generate_test_message("button4", capabilities.button.button.held({ state_change = true })) + ) + + http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_4, "long_press") }) + + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua new file mode 100644 index 0000000000..5e562717c9 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua @@ -0,0 +1,101 @@ +--- Tests for an on/off Hue smart plug (LOM010), built from payloads recorded on real hardware +--- (see test/fixtures/capture_payloads.lua). The plug is a `light` resource with no `dimming`. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local capture_payloads = require "test.fixtures.capture_payloads" + +local PLUG_RID = capture_payloads.ids.plug_light +local PLUG_DEVICE_ID = capture_payloads.ids.plug_device +local PLUG_ZIGBEE_RID = capture_payloads.ids.plug_zigbee + +local function plug_light_resource(on) + local resp = capture_payloads.get("plug_light") + if on ~= nil then resp.data[1].on.on = on end + return resp +end + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(PLUG_RID, { + on = { on = true }, + hue_device_id = PLUG_DEVICE_ID, + label = "Hue smart plug 1", + }, "plug.yml") + :start() + +local mock_bridge, mock_plug = fixtures.bridge, fixtures.devices[1] + +local scenario, conns = hue_test_helpers.create_hue_scenario() +local rest = conns.rest + +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +http.expect_request(rest, "GET", "/clip/v2/resource/device/" .. PLUG_DEVICE_ID, { + status = 200, + body = capture_payloads.get("plug_device"), +}) +hue_test_helpers.expect_zigbee_connectivity(rest, PLUG_ZIGBEE_RID, { owner = PLUG_DEVICE_ID, reusable = true }) + +local function refresh() + test.socket.capability:__queue_receive({ + mock_plug.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities["samsungim.hueSyncMode"].mode("normal"))) +end + +test.register_coroutine_test( + "refreshing a plug emits its switch and sync mode state", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. PLUG_RID, { + status = 200, + body = plug_light_resource(), + }) + + refresh() + test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities.switch.switch.on())) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "refreshing a plug that is off emits switch off", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. PLUG_RID, { + status = 200, + body = plug_light_resource(false), + }) + + refresh() + test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities.switch.switch.off())) + test.wait_for_events() + end +) + +for _, command in ipairs({ "on", "off" }) do + test.register_coroutine_test( + "switch " .. command .. " sends a PUT to the plug's light resource", + function() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. PLUG_RID, { + status = 200, + body = { data = { { rid = PLUG_RID, rtype = "light" } }, errors = {} }, + }) + + test.socket.capability:__queue_receive({ + mock_plug.id, + { capability = "switch", component = "main", command = command, args = {} }, + }) + test.wait_for_events() + + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. PLUG_RID, { on = { on = command == "on" } }) + end + ) +end + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua b/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua index f169381eed..d260645d91 100644 --- a/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua @@ -14,6 +14,12 @@ local grouped_utils = {} grouped_utils.GROUP_TYPES = {room = true, zone = true} +-- Lets tests (and, in principle, a future driver preference) suppress group scanning entirely, +-- without needing to mock rooms/zones REST responses or race the scan's own 45-second debounce +-- timing against whatever else a test is asserting on the same connection. Defaults to true; +-- production code never touches this. +grouped_utils.scanning_enabled = true + --- Build up mapping of hue device id to SmartThings device record ---@param bridge_device HueBridgeDevice ---@return table @@ -138,13 +144,16 @@ function grouped_utils.scan_groups(driver, bridge_device, api, hue_id_to_device) local rooms, zones -- These are the hue light/other service ids rather than the hue device ids local light_id_to_device = utils.get_hue_id_to_device_table_by_bridge(driver, bridge_device) or {} - while not (rooms and zones) do + local backoff = utils.backoff_builder(30, 1, 0.5) + while true do if not rooms then rooms = handle_group_scan_response("rooms", driver, hue_id_to_device, light_id_to_device, api:get_rooms()) end if not zones then zones = handle_group_scan_response("zones", driver, hue_id_to_device, light_id_to_device, api:get_zones()) end + if rooms and zones then break end + cosock.socket.sleep(backoff()) end -- Combine rooms and zones. for _, zone in ipairs(zones) do @@ -283,6 +292,9 @@ end function grouped_utils.queue_group_scan(driver, bridge_device) + if not grouped_utils.scanning_enabled then + return + end local queue = bridge_device:get_field(Fields.GROUPS_SCAN_QUEUE) if queue == nil then local tx, rx = cosock.channel.new() diff --git a/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua index 05905b80ff..e33de22a86 100644 --- a/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua @@ -41,10 +41,14 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u nil ) local hue_identifier_to_device_record = utils.get_hue_id_to_device_table_by_bridge(driver, bridge_device) or {} + -- Whether the current outage has already been reported; the EventSource calls `onerror` on + -- every failed reconnect attempt. + local marked_offline = false eventsource.onopen = function(msg) log.info_with({ hub_logs = true }, string.format("Event Source Connection for Hue Bridge \"%s\" established, marking online", bridge_device.label)) + marked_offline = false bridge_device:online() local bridge_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] @@ -71,18 +75,13 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u end local scanned = false - local connectivity_status, rest_err - + local backoff = utils.backoff_builder(30, 1, 0.5) while true do - if scanned then break end - connectivity_status, rest_err = bridge_api:get_connectivity_status() + local connectivity_status, rest_err = bridge_api:get_connectivity_status() if rest_err ~= nil or not connectivity_status then log.error(string.format("Couldn't query Hue Bridge %s for zigbee connectivity status for child devices: %s", bridge_device.label, st_utils.stringify_table(rest_err, "Rest Error", true))) - goto continue - end - - if connectivity_status.errors and #connectivity_status.errors > 0 then + elseif connectivity_status.errors and #connectivity_status.errors > 0 then log.error( string.format( "Hue Bridge %s replied with the following error message(s) " .. @@ -93,10 +92,7 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u for idx, err in ipairs(connectivity_status.errors) do log.error(string.format("--- %s", st_utils.stringify_table(err, string.format("Error %s:", idx), true))) end - goto continue - end - - if connectivity_status.data and #connectivity_status.data > 0 then + elseif connectivity_status.data and #connectivity_status.data > 0 then scanned = true for _, status in ipairs(connectivity_status.data) do local hue_device_id = (status.owner and status.owner.rid) or "" @@ -125,7 +121,8 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u end end - ::continue:: + if scanned then break end + cosock.socket.sleep(backoff()) end grouped_utils.queue_group_scan(driver, bridge_device) end, string.format("Hue Bridge %s On Connect Task", bridge_device.label)) @@ -133,6 +130,8 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u eventsource.onerror = function() log.error_with({ hub_logs = true }, string.format("Hue Bridge \"%s\" Event Source Error", bridge_device.label)) + if marked_offline then return end + marked_offline = true for _, device_record in ipairs(bridge_device:get_child_list()) do device_record:set_field(Fields.IS_ONLINE, false) @@ -146,8 +145,12 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u if msg and msg.data then local json_result = table.pack(pcall(json.decode, msg.data)) local success = table.remove(json_result, 1) + -- json.decode (dkjson) returns `value, position, err` -- `position` (the index it + -- stopped scanning at) is a non-nil number even on a fully successful decode, so it + -- has to be captured and discarded here rather than accidentally landing in `err`, + -- which would otherwise make every SSE message look like a JSON parse error. ---@type HueSseEvent[], string? - local events, err = table.unpack(json_result, 1, json_result.n) + local events, _, err = table.unpack(json_result, 1, json_result.n) if not success then log.error_with(