From a8b3846aa687adc3ffc0a499e4fc5fd6676ec867 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Wed, 12 Aug 2026 18:57:17 -0500 Subject: [PATCH 01/19] philips-hue: Fix a few inconsistencies with json decodingn dkjson.decode returns (value, next_position, error_message), but process_rest_response propagated all of pcall's captured return values after decoding, not just the decoded value its own doc comment promises. That means the parse position (e.g. 74 for a 73-byte body) gets returned in the position every caller treats as `err`, so every successful REST call with a JSON body logs a spurious "Error performing : ". Found via the first integration test to exercise a real, successful JSON-decoded REST response through this path. Co-Authored-By: Claude Sonnet 5 philips-hue: fix onmessage misreading json.decode's position as an error table.pack(pcall(json.decode, msg.data)) followed by table.remove(...,1) to strip the pcall success flag left `events, err = table.unpack(...)` capturing dkjson's second return value (the position it stopped scanning at, a non-nil number even on success) into `err` instead of its real third return value. Every SSE message was therefore logged as a JSON parse error and dropped without ever reaching the update/add/delete handling below -- there was no prior test coverage of this path to catch it. Co-Authored-By: Claude Sonnet 5 --- drivers/SmartThings/philips-hue/src/hue/api.lua | 6 +++++- .../SmartThings/philips-hue/src/utils/hue_bridge_utils.lua | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) 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/utils/hue_bridge_utils.lua b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua index 05905b80ff..d52b92bc55 100644 --- a/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua @@ -146,8 +146,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( From e8c0547a1ecf8adb84646dc6920bf1dcf3d83162 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Wed, 12 Aug 2026 18:57:37 -0500 Subject: [PATCH 02/19] Add initial set of LAN integration tests for Hue Co-Authored-By: Claude Sonnet 5 --- .../philips-hue/src/test/hue_test_helpers.lua | 1262 +++++++++++++++++ .../src/test/test_hue_bridge_discovery.lua | 122 ++ .../src/test/test_hue_bridge_sse.lua | 293 ++++ .../src/test/test_hue_button_lifecycle.lua | 41 + .../src/test/test_hue_button_sse.lua | 147 ++ .../test/test_hue_child_device_lifecycle.lua | 65 + .../src/test/test_hue_contact_sensor_sse.lua | 143 ++ .../src/test/test_hue_error_handling.lua | 274 ++++ .../src/test/test_hue_light_commands.lua | 236 +++ .../src/test/test_hue_light_refresh.lua | 102 ++ .../src/test/test_hue_motion_sensor_sse.lua | 155 ++ .../src/test/test_hue_multibutton_sse.lua | 132 ++ .../philips-hue/src/utils/grouped_utils.lua | 14 +- .../src/utils/hue_bridge_utils.lua | 19 +- 14 files changed, 2991 insertions(+), 14 deletions(-) create mode 100644 drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_button_lifecycle.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_button_sse.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_child_device_lifecycle.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_contact_sensor_sse.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_light_refresh.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_motion_sensor_sse.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_multibutton_sse.lua 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..a41d65a0d4 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -0,0 +1,1262 @@ +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. +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 builder = hue_test_helpers.HueDeviceBuilder.new() +--- :with_bridge() +--- :with_button("button-rid", { num_buttons = 1, battery = 85 }) +--- :enable_sse() +--- +--- local mock_bridge, mock_button, get_bridge_server, test_init, get_sse_connection = builder:start() +--- +--- -- Setup ConnectionScenario 2.0 +--- local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +--- local rest, sse = conns.rest, conns.sse +--- +--- hue_test_helpers.setup_scenario_test_init(test_init, scenario) +--- +--- -- Use helper functions: +--- hue_test_helpers.expect_device_info(rest, device_id, services) +--- hue_test_helpers.setup_sse_expectations(sse, rest) +--- +--- -- Send SSE events: +--- 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 +--- +--- ### Helper Functions: +--- +--- **Connection Setup:** +--- - `create_hue_scenario(options)` - Create ConnectionScenario with REST/SSE +--- - `setup_scenario_test_init(base, scenario, additional)` - Setup test init +--- +--- **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 + +--- Enable SSE support for this fixture. +--- +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:enable_sse() + self.sse_enabled = true + return self +end + +--- Build the fixture and return handles. +--- This creates all mock devices and returns functions to access them. +--- +--- @return table mock_bridge +--- @return table... mock_children (one per child device) +--- @return fun() get_bridge_server +--- @return fun() test_init (must be passed to test.set_test_init_function) +--- @return fun() get_sse_connection (only if enable_sse was called) +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 mock_bridge_server + local mock_sse_connection + + test.add_test_env_setup_func(function(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 + + mock_bridge_server = lan_test_utils.build_mock_server(self.bridge_ip, 443) + if self.sse_enabled then + mock_sse_connection = mock_bridge_server:reserve_connection("sse") + 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 + -- Lights always emit levelRange on init + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.switchLevel.levelRange({ minimum = 1, maximum = 100 }) + ) + ) + end + end + end + + local function get_bridge_server() + assert(mock_bridge_server, "get_bridge_server() called before test_init() has run") + return mock_bridge_server + end + + local function get_sse_connection() + assert(mock_sse_connection, "get_sse_connection() called without enable_sse(), or before test_init() has run") + return mock_sse_connection + end + + -- Return mock_bridge, all mock_children, get_bridge_server, test_init, get_sse_connection + local results = {mock_bridge} + for _, mock_child in ipairs(mock_children) do + table.insert(results, mock_child) + end + table.insert(results, get_bridge_server) + table.insert(results, test_init) + if self.sse_enabled then + table.insert(results, get_sse_connection) + end + + return table.unpack(results) +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 for REST matcher (default: "GET") +--- - 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 + connections.rest = scenario:connection(options.rest_name or "rest", { + matcher = http.matcher(options.rest_method or "GET", "/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 + +return M 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..91e91a3c0b --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua @@ -0,0 +1,122 @@ +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.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..0aaf567b7b --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_button_lifecycle.lua @@ -0,0 +1,41 @@ +--- Test for button device lifecycle (added/init/removed). +--- Migrated to use connection_scenario 2.0. +--- +--- 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 mock_bridge, mock_button, get_bridge_server, test_init = + hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_button(BUTTON_RID, { + battery = 85, + device_id = BUTTON_DEVICE_ID, + power_rid = POWER_RID, + }) + :start() + +test.set_test_init_function(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..30609c5642 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_button_sse.lua @@ -0,0 +1,147 @@ +--- Test for Hue button device with SSE events. +--- Rewritten to use connection_scenario 2.0 and hue_test_helpers. + +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 builder = 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() + +local mock_bridge, mock_button, get_bridge_server, base_test_init, get_sse_connection = builder:start() + +-- 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 + +-- Configure expected init-time REST requests (relaxed ordering) +-- 1. GET device info (reusable: may be called multiple times during refresh) +hue_test_helpers.expect_device_info(rest, BUTTON_DEVICE_ID, { + { rtype = "zigbee_connectivity", rid = ZIGBEE_RID }, + { rtype = "button", rid = BUTTON_RID }, + { rtype = "device_power", rid = POWER_RID }, +}, { + name = "Hue Button", + product_data = { product_name = "Hue Button" }, + reusable = true +}) + +-- 2. GET zigbee connectivity +hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID) + +-- 3. GET button info +hue_test_helpers.expect_button_resource(rest, BUTTON_RID) + +-- 4. GET device power +hue_test_helpers.expect_device_power(rest, POWER_RID, 85) + +-- 5. SSE handshake and connectivity poll +hue_test_helpers.setup_sse_expectations(sse, rest) + +-- 6. Room resource query (reusable: may be called multiple times) +http.expect_request(rest, "GET", "/clip/v2/resource/room", { + status = 200, + body = { + errors = {}, + data = {} -- Empty room list is fine + }, + reusable = true +}) + +-- 7. Zone resource query (reusable: may be called multiple times) +http.expect_request(rest, "GET", "/clip/v2/resource/zone", { + status = 200, + body = { + errors = {}, + data = {} -- Empty zone list is fine + }, + reusable = true +}) + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(base_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..60def50ed2 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_child_device_lifecycle.lua @@ -0,0 +1,65 @@ +--- Test for child device lifecycle with uncached devices (stray device handling). +--- Migrated to use connection_scenario 2.0. +--- +--- 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 mock_bridge, mock_throwaway_light, get_bridge_server, base_test_init = + hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(THROWAWAY_LIGHT_RID, { + on = { on = true } + }, "white-and-color-ambiance.yml") + :start() + +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() + base_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..b0f09bbace --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_contact_sensor_sse.lua @@ -0,0 +1,143 @@ +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 mock_bridge, mock_sensor, get_bridge_server, base_test_init, get_sse_connection = + 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() + +-- 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 test init with scenario activation +hue_test_helpers.setup_scenario_test_init(base_test_init, scenario) + +-- 1. GET device info (reusable: may be called multiple times during refresh) +hue_test_helpers.expect_device_info(rest, CONTACT_DEVICE_ID, { + { rtype = "zigbee_connectivity", rid = ZIGBEE_RID }, + { rtype = "contact", rid = CONTACT_RID }, + { rtype = "tamper", rid = TAMPER_RID }, + { rtype = "device_power", rid = POWER_RID } +}, { + name = "Hue Contact Sensor", + reusable = true +}) + +-- 2. GET zigbee connectivity +hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID) + +-- 3. GET contact sensor info +hue_test_helpers.expect_contact_resource(rest, CONTACT_RID, "contact") + +-- 4. GET tamper info +hue_test_helpers.expect_tamper_resource(rest, TAMPER_RID, "not_tampered") + +-- 5. GET device power +hue_test_helpers.expect_device_power(rest, POWER_RID, 90) + +-- 6. SSE handshake and connectivity poll +hue_test_helpers.setup_sse_expectations(sse, rest) + +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_error_handling.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua new file mode 100644 index 0000000000..ea66e5cc19 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua @@ -0,0 +1,274 @@ +--- Test for error handling in Hue light commands and refresh operations. +--- Migrated to use connection_scenario 2.0. +--- +--- 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 mock_bridge, mock_light, get_bridge_server, base_test_init = + 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() + +-- 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(base_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.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..310f9ab34c --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua @@ -0,0 +1,236 @@ +--- Test for Hue light command handling (switch, level, color, temperature). +--- Migrated to use connection_scenario 2.0 with helper functions. + +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 mock_bridge, mock_light, get_bridge_server, base_test_init = + 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() + +-- 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(base_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 +) + +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..05ca08a67a --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_refresh.lua @@ -0,0 +1,102 @@ +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 mock_bridge, mock_light, get_bridge_server, base_test_init = + 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() + +-- 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(base_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..f3976d83d8 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_motion_sensor_sse.lua @@ -0,0 +1,155 @@ +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 mock_bridge, mock_sensor, get_bridge_server, base_test_init, get_sse_connection = + 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() + +-- 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 test init with scenario activation +hue_test_helpers.setup_scenario_test_init(base_test_init, scenario) + +-- 1. GET device info (reusable: may be called multiple times during refresh) +hue_test_helpers.expect_device_info(rest, MOTION_DEVICE_ID, { + { rtype = "zigbee_connectivity", rid = ZIGBEE_RID }, + { rtype = "motion", rid = MOTION_RID }, + { rtype = "temperature", rid = TEMP_RID }, + { rtype = "light_level", rid = LIGHT_RID }, + { rtype = "device_power", rid = POWER_RID } +}, { + name = "Hue Motion Sensor", + reusable = true +}) + +-- 2. GET zigbee connectivity +hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID) + +-- 3. GET motion sensor info +hue_test_helpers.expect_motion_resource(rest, MOTION_RID, false) + +-- 4. GET temperature info +hue_test_helpers.expect_temperature_resource(rest, TEMP_RID, 20.0) + +-- 5. GET light level info +hue_test_helpers.expect_light_level_resource(rest, LIGHT_RID, 30000) + +-- 6. GET device power +hue_test_helpers.expect_device_power(rest, POWER_RID, 95) + +-- 7. SSE handshake and connectivity poll +hue_test_helpers.setup_sse_expectations(sse, rest) + +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..3c04946cf0 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_multibutton_sse.lua @@ -0,0 +1,132 @@ +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 mock_bridge, mock_remote, get_bridge_server, base_test_init, get_sse_connection = + 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() + +-- 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 test init with scenario activation +hue_test_helpers.setup_scenario_test_init(base_test_init, scenario) + +-- 1. GET device info (reusable: may be called multiple times during refresh) +hue_test_helpers.expect_device_info(rest, BUTTON_DEVICE_ID, { + { rtype = "zigbee_connectivity", rid = ZIGBEE_RID }, + { rtype = "button", rid = BUTTON_RID_1 }, + { rtype = "button", rid = BUTTON_RID_2 }, + { rtype = "button", rid = BUTTON_RID_3 }, + { rtype = "button", rid = BUTTON_RID_4 }, + { rtype = "device_power", rid = POWER_RID }, +}, { + name = "Hue Dimmer Remote", + product_data = { product_name = "Hue Dimmer Remote" }, + reusable = true +}) + +-- 2. GET zigbee connectivity +hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID) + +-- 3-6. GET button info for all 4 buttons +hue_test_helpers.expect_button_resource(rest, BUTTON_RID_1, { control_id = 1 }) +hue_test_helpers.expect_button_resource(rest, BUTTON_RID_2, { control_id = 2 }) +hue_test_helpers.expect_button_resource(rest, BUTTON_RID_3, { control_id = 3 }) +hue_test_helpers.expect_button_resource(rest, BUTTON_RID_4, { control_id = 4 }) + +-- 7. GET device power +hue_test_helpers.expect_device_power(rest, POWER_RID, 90) + +-- 8. SSE handshake and connectivity poll +hue_test_helpers.setup_sse_expectations(sse, rest) + +-- 9. Room resource query (reusable) +http.expect_request(rest, "GET", "/clip/v2/resource/room", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true +}) + +-- 10. Zone resource query (reusable) +http.expect_request(rest, "GET", "/clip/v2/resource/zone", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true +}) + +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/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 d52b92bc55..c73f526127 100644 --- a/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua @@ -71,18 +71,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 +88,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 +117,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)) From c3893ff9d9623aa583c09335c80b3ff7f4264485 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 20 Aug 2026 13:45:39 -0500 Subject: [PATCH 03/19] Cleanup HueDeviceBuilder test util --- .../philips-hue/src/test/hue_test_helpers.lua | 457 ++++++++++++++++-- .../src/test/test_hue_button_lifecycle.lua | 23 +- .../src/test/test_hue_button_sse.lua | 57 +-- .../test/test_hue_child_device_lifecycle.lua | 19 +- .../src/test/test_hue_contact_sensor_sse.lua | 60 +-- .../src/test/test_hue_error_handling.lua | 23 +- .../src/test/test_hue_light_commands.lua | 27 +- .../src/test/test_hue_light_refresh.lua | 21 +- .../src/test/test_hue_motion_sensor_sse.lua | 68 +-- .../src/test/test_hue_multibutton_sse.lua | 77 +-- 10 files changed, 543 insertions(+), 289 deletions(-) diff --git a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua index a41d65a0d4..0fee5735d9 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -66,24 +66,25 @@ end --- ### Example: Button Device with SSE --- --- ```lua ---- local builder = hue_test_helpers.HueDeviceBuilder.new() +--- 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, get_bridge_server, test_init, get_sse_connection = builder: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 --- ---- hue_test_helpers.setup_scenario_test_init(test_init, scenario) +--- -- Setup expectations using device-specific helper +--- hue_test_helpers.setup_button_init_expectations(rest, sse, fixtures.configs.button[1]) --- ---- -- Use helper functions: ---- hue_test_helpers.expect_device_info(rest, device_id, services) ---- hue_test_helpers.setup_sse_expectations(sse, rest) +--- -- Activate scenario +--- hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) --- ---- -- Send SSE events: +--- -- Send SSE events in tests: --- http.queue_sse_event(sse, { hue_test_helpers.button_event(button_rid, "short_release") }) --- ``` --- @@ -95,7 +96,11 @@ end --- - `with_motion(rid, config)` - Add motion sensor --- - `with_contact(rid, config)` - Add contact sensor --- - `enable_sse()` - Enable SSE support ---- - `start()` - Build fixtures +--- - `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: --- @@ -103,7 +108,13 @@ end --- - `create_hue_scenario(options)` - Create ConnectionScenario with REST/SSE --- - `setup_scenario_test_init(base, scenario, additional)` - Setup test init --- ---- **REST Expectations:** +--- **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 @@ -112,6 +123,7 @@ end --- - `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. @@ -380,14 +392,14 @@ function HueDeviceBuilder:enable_sse() return self end ---- Build the fixture and return handles. ---- This creates all mock devices and returns functions to access them. +--- Build the fixture and return a structured fixtures object. +--- This creates all mock devices and returns them with their configurations. --- ---- @return table mock_bridge ---- @return table... mock_children (one per child device) ---- @return fun() get_bridge_server ---- @return fun() test_init (must be passed to test.set_test_init_function) ---- @return fun() get_sse_connection (only if enable_sse was called) +--- @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", @@ -406,9 +418,6 @@ function HueDeviceBuilder:start() table.insert(mock_children, test.mock_device.build_test_lan_device(child_template)) end - local mock_bridge_server - local mock_sse_connection - test.add_test_env_setup_func(function(driver) driver.datastore.bridge_netinfo = driver.datastore.bridge_netinfo or {} if self.sse_enabled then @@ -472,11 +481,6 @@ function HueDeviceBuilder:start() end end - mock_bridge_server = lan_test_utils.build_mock_server(self.bridge_ip, 443) - if self.sse_enabled then - mock_sse_connection = mock_bridge_server:reserve_connection("sse") - 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 @@ -589,28 +593,81 @@ function HueDeviceBuilder:start() end end - local function get_bridge_server() - assert(mock_bridge_server, "get_bridge_server() called before test_init() has run") - return mock_bridge_server - end - - local function get_sse_connection() - assert(mock_sse_connection, "get_sse_connection() called without enable_sse(), or before test_init() has run") - return mock_sse_connection - end - - -- Return mock_bridge, all mock_children, get_bridge_server, test_init, get_sse_connection - local results = {mock_bridge} - for _, mock_child in ipairs(mock_children) do - table.insert(results, mock_child) - end - table.insert(results, get_bridge_server) - table.insert(results, test_init) - if self.sse_enabled then - table.insert(results, get_sse_connection) + -- 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 - return table.unpack(results) + -- Return structured fixtures object + return { + bridge = mock_bridge, + devices = mock_children, + test_init = test_init, + configs = configs + } end -- Export HueDeviceBuilder via a constructor function @@ -1259,4 +1316,316 @@ 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_button_lifecycle.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_button_lifecycle.lua index 0aaf567b7b..fb402765c4 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_button_lifecycle.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_button_lifecycle.lua @@ -1,5 +1,5 @@ --- Test for button device lifecycle (added/init/removed). ---- Migrated to use connection_scenario 2.0. +--- 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 @@ -15,17 +15,18 @@ 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 mock_bridge, mock_button, get_bridge_server, test_init = - hue_test_helpers.HueDeviceBuilder.new() - :with_bridge() - :with_button(BUTTON_RID, { - battery = 85, - device_id = BUTTON_DEVICE_ID, - power_rid = POWER_RID, - }) - :start() +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() -test.set_test_init_function(test_init) +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", 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 index 30609c5642..a17e2f3cc5 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_button_sse.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_button_sse.lua @@ -1,5 +1,5 @@ --- Test for Hue button device with SSE events. ---- Rewritten to use connection_scenario 2.0 and hue_test_helpers. +--- Uses HueDeviceBuilder and ConnectionScenario 2.0 with device-type-specific helper. local test = require "integration_test" local capabilities = require "st.capabilities" @@ -13,7 +13,7 @@ local POWER_RID = "aaaaaaaa-bbbb-cccc-dddd-333333333333" local ZIGBEE_RID = "aaaaaaaa-bbbb-cccc-dddd-444444444444" -- Create test fixture using HueDeviceBuilder -local builder = hue_test_helpers.HueDeviceBuilder.new() +local fixtures = hue_test_helpers.HueDeviceBuilder.new() :with_bridge() :with_button(BUTTON_RID, { num_buttons = 1, @@ -23,59 +23,22 @@ local builder = hue_test_helpers.HueDeviceBuilder.new() power_rid = POWER_RID }) :enable_sse() + :start() -local mock_bridge, mock_button, get_bridge_server, base_test_init, get_sse_connection = builder: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 --- Configure expected init-time REST requests (relaxed ordering) --- 1. GET device info (reusable: may be called multiple times during refresh) -hue_test_helpers.expect_device_info(rest, BUTTON_DEVICE_ID, { - { rtype = "zigbee_connectivity", rid = ZIGBEE_RID }, - { rtype = "button", rid = BUTTON_RID }, - { rtype = "device_power", rid = POWER_RID }, -}, { - name = "Hue Button", - product_data = { product_name = "Hue Button" }, - reusable = true -}) - --- 2. GET zigbee connectivity -hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID) - --- 3. GET button info -hue_test_helpers.expect_button_resource(rest, BUTTON_RID) - --- 4. GET device power -hue_test_helpers.expect_device_power(rest, POWER_RID, 85) - --- 5. SSE handshake and connectivity poll -hue_test_helpers.setup_sse_expectations(sse, rest) - --- 6. Room resource query (reusable: may be called multiple times) -http.expect_request(rest, "GET", "/clip/v2/resource/room", { - status = 200, - body = { - errors = {}, - data = {} -- Empty room list is fine - }, - reusable = true -}) - --- 7. Zone resource query (reusable: may be called multiple times) -http.expect_request(rest, "GET", "/clip/v2/resource/zone", { - status = 200, - body = { - errors = {}, - data = {} -- Empty zone list is fine - }, - reusable = true -}) +-- 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(base_test_init, scenario) +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) test.register_coroutine_test( "SSE connection establishes successfully for button device", 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 index 60def50ed2..db44eb3f62 100644 --- 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 @@ -1,6 +1,4 @@ --- Test for child device lifecycle with uncached devices (stray device handling). ---- Migrated to use connection_scenario 2.0. ---- --- Tests that uncached child devices are properly marked as "stray" rather than --- attempting to fetch their state via REST (which would fail/crash). --- @@ -21,13 +19,14 @@ local hue_test_helpers = require "test.hue_test_helpers" local THROWAWAY_LIGHT_RID = "33333333-3333-3333-3333-333333333333" local NEW_LIGHT_RID = "44444444-4444-4444-4444-444444444444" -local mock_bridge, mock_throwaway_light, get_bridge_server, base_test_init = - hue_test_helpers.HueDeviceBuilder.new() - :with_bridge() - :with_light(THROWAWAY_LIGHT_RID, { - on = { on = true } - }, "white-and-color-ambiance.yml") - :start() +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", @@ -37,7 +36,7 @@ local mock_new_light = test.mock_device.build_test_lan_device({ }) local function test_init() - base_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 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 index b0f09bbace..ea92f63a02 100644 --- 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 @@ -13,52 +13,32 @@ local CONTACT_DEVICE_ID = "gggggggg-gggg-gggg-gggg-gggggggggggg" local ZIGBEE_RID = "zigbee-rid-1" -- Contact sensor fixture WITH SSE enabled -local mock_bridge, mock_sensor, get_bridge_server, base_test_init, get_sse_connection = - 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 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 test init with scenario activation -hue_test_helpers.setup_scenario_test_init(base_test_init, scenario) - --- 1. GET device info (reusable: may be called multiple times during refresh) -hue_test_helpers.expect_device_info(rest, CONTACT_DEVICE_ID, { - { rtype = "zigbee_connectivity", rid = ZIGBEE_RID }, - { rtype = "contact", rid = CONTACT_RID }, - { rtype = "tamper", rid = TAMPER_RID }, - { rtype = "device_power", rid = POWER_RID } -}, { - name = "Hue Contact Sensor", - reusable = true -}) - --- 2. GET zigbee connectivity -hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID) - --- 3. GET contact sensor info -hue_test_helpers.expect_contact_resource(rest, CONTACT_RID, "contact") +-- 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) --- 4. GET tamper info -hue_test_helpers.expect_tamper_resource(rest, TAMPER_RID, "not_tampered") - --- 5. GET device power -hue_test_helpers.expect_device_power(rest, POWER_RID, 90) - --- 6. SSE handshake and connectivity poll -hue_test_helpers.setup_sse_expectations(sse, rest) +-- 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", 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 index ea66e5cc19..1958885160 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua @@ -1,5 +1,5 @@ --- Test for error handling in Hue light commands and refresh operations. ---- Migrated to use connection_scenario 2.0. +--- Uses ConnectionScenario 2.0 for testing various error scenarios. --- --- Tests various error scenarios: 404, 500, API errors, timeouts, malformed JSON, etc. @@ -14,15 +14,16 @@ 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 mock_bridge, mock_light, get_bridge_server, base_test_init = - 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 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 @@ -41,7 +42,7 @@ local get_conn = scenario:connection("get", { }) -- Setup test init with scenario activation -hue_test_helpers.setup_scenario_test_init(base_test_init, scenario) +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) test.register_coroutine_test( "Light command handles 404 error gracefully", 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 index 310f9ab34c..e9fc487900 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua @@ -1,5 +1,5 @@ --- Test for Hue light command handling (switch, level, color, temperature). ---- Migrated to use connection_scenario 2.0 with helper functions. +--- Uses ConnectionScenario 2.0 for PUT command testing. local test = require "integration_test" local hue_test_helpers = require "test.hue_test_helpers" @@ -7,17 +7,18 @@ local http = require "integration_test.connection_scenario_http" local LIGHT_RID = "11111111-1111-1111-1111-111111111111" -local mock_bridge, mock_light, get_bridge_server, base_test_init = - 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 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({ @@ -27,7 +28,7 @@ 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(base_test_init, scenario) +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. 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 index 05ca08a67a..b8951f68f2 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_light_refresh.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_refresh.lua @@ -7,22 +7,23 @@ local LIGHT_RID = "22222222-2222-2222-2222-222222222222" local HUE_DEVICE_ID = "device-uuid-1" local ZIGBEE_RID = "zigbee-conn-1" -local mock_bridge, mock_light, get_bridge_server, base_test_init = - 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 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(base_test_init, scenario) +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), 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 index f3976d83d8..9102bd0739 100644 --- 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 @@ -12,58 +12,34 @@ local MOTION_DEVICE_ID = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" local ZIGBEE_RID = "zigbee-rid-1" -- Motion sensor fixture WITH SSE enabled -local mock_bridge, mock_sensor, get_bridge_server, base_test_init, get_sse_connection = - 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 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 test init with scenario activation -hue_test_helpers.setup_scenario_test_init(base_test_init, scenario) - --- 1. GET device info (reusable: may be called multiple times during refresh) -hue_test_helpers.expect_device_info(rest, MOTION_DEVICE_ID, { - { rtype = "zigbee_connectivity", rid = ZIGBEE_RID }, - { rtype = "motion", rid = MOTION_RID }, - { rtype = "temperature", rid = TEMP_RID }, - { rtype = "light_level", rid = LIGHT_RID }, - { rtype = "device_power", rid = POWER_RID } -}, { - name = "Hue Motion Sensor", - reusable = true -}) - --- 2. GET zigbee connectivity -hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID) - --- 3. GET motion sensor info -hue_test_helpers.expect_motion_resource(rest, MOTION_RID, false) +-- 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) --- 4. GET temperature info -hue_test_helpers.expect_temperature_resource(rest, TEMP_RID, 20.0) - --- 5. GET light level info -hue_test_helpers.expect_light_level_resource(rest, LIGHT_RID, 30000) - --- 6. GET device power -hue_test_helpers.expect_device_power(rest, POWER_RID, 95) - --- 7. SSE handshake and connectivity poll -hue_test_helpers.setup_sse_expectations(sse, rest) +-- 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", 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 index 3c04946cf0..9f91763e44 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_multibutton_sse.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_multibutton_sse.lua @@ -13,69 +13,32 @@ local POWER_RID = "cccccccc-cccc-cccc-cccc-cccccccccccc" local ZIGBEE_RID = "zigbee-rid-1" -- 4-button remote fixture WITH SSE enabled -local mock_bridge, mock_remote, get_bridge_server, base_test_init, get_sse_connection = - 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 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 test init with scenario activation -hue_test_helpers.setup_scenario_test_init(base_test_init, scenario) - --- 1. GET device info (reusable: may be called multiple times during refresh) -hue_test_helpers.expect_device_info(rest, BUTTON_DEVICE_ID, { - { rtype = "zigbee_connectivity", rid = ZIGBEE_RID }, - { rtype = "button", rid = BUTTON_RID_1 }, - { rtype = "button", rid = BUTTON_RID_2 }, - { rtype = "button", rid = BUTTON_RID_3 }, - { rtype = "button", rid = BUTTON_RID_4 }, - { rtype = "device_power", rid = POWER_RID }, -}, { - name = "Hue Dimmer Remote", - product_data = { product_name = "Hue Dimmer Remote" }, - reusable = true -}) - --- 2. GET zigbee connectivity -hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID) - --- 3-6. GET button info for all 4 buttons -hue_test_helpers.expect_button_resource(rest, BUTTON_RID_1, { control_id = 1 }) -hue_test_helpers.expect_button_resource(rest, BUTTON_RID_2, { control_id = 2 }) -hue_test_helpers.expect_button_resource(rest, BUTTON_RID_3, { control_id = 3 }) -hue_test_helpers.expect_button_resource(rest, BUTTON_RID_4, { control_id = 4 }) +-- 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) --- 7. GET device power -hue_test_helpers.expect_device_power(rest, POWER_RID, 90) - --- 8. SSE handshake and connectivity poll -hue_test_helpers.setup_sse_expectations(sse, rest) - --- 9. Room resource query (reusable) -http.expect_request(rest, "GET", "/clip/v2/resource/room", { - status = 200, - body = { errors = {}, data = {} }, - reusable = true -}) - --- 10. Zone resource query (reusable) -http.expect_request(rest, "GET", "/clip/v2/resource/zone", { - status = 200, - body = { errors = {}, data = {} }, - reusable = true -}) +-- 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", From 6b2d7b9379674f05b67aca19e1da6481b8a2d317 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Tue, 13 Oct 2026 08:23:28 -0500 Subject: [PATCH 04/19] philips-hue: Add LAN integration tests for batched command routing Cover how the batch capability message handler sends commands to the bridge: a batch exactly covering a room becomes one grouped_light request, and everything else (batches of one, lights outside the room, partial room matches, mismatched arguments, commands without a grouped handler) goes out as per-light requests. Adds HueDeviceBuilder:with_room() and fixtures.seed_groups(), which feeds Hue room resources through grouped_utils.group_add once lights are initialized, since group scanning stays disabled in tests. Requires the integration_test `_capability_batch_receive` opt-in from the scripting-engine lan-integration-test-support branch. Co-Authored-By: Claude Opus 5.5 --- .../philips-hue/src/test/hue_test_helpers.lua | 55 ++++- .../src/test/test_hue_batch_commands.lua | 211 ++++++++++++++++++ 2 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua diff --git a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua index 0fee5735d9..04523cd8f8 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -384,6 +384,28 @@ function HueDeviceBuilder:with_contact(rid, config) 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 @@ -418,7 +440,9 @@ function HueDeviceBuilder:start() 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] = { @@ -661,12 +685,41 @@ function HueDeviceBuilder:start() 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 + -- Return structured fixtures object return { bridge = mock_bridge, devices = mock_children, test_init = test_init, - configs = configs + configs = configs, + seed_groups = seed_groups, } end 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..1b4516993e --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua @@ -0,0 +1,211 @@ +--- 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 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 + +--- Count requests sent in this test whose request line targets `path` (plain-text match). +local function count_requests(method, path) + local sent = rest:get_sent_log() + local needle = method .. " " .. path + local count, start = 0, 1 + while true do + local i = sent:find(needle, start, true) + if not i then return count end + count = count + 1 + start = i + #needle + end +end + +local function assert_requests(method, path, expected) + local actual = count_requests(method, path) + 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) + rest:assert_sent('"on":%s*{%s*"on":%s*true%s*}') + 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) + rest:assert_sent('"on":%s*{%s*"on":%s*true%s*}') + 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) + rest:assert_sent('"on":%s*{%s*"on":%s*true%s*}') + rest:assert_sent('"on":%s*{%s*"on":%s*false%s*}') + 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 +) + +test.run_registered_tests() From 727f8f1160959b5af0e70d2056683a0c0f035974 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 10:25:07 -0500 Subject: [PATCH 05/19] philips-hue: Add recorded bridge payload fixtures for tests test/fixtures/capture_payloads.lua holds real CLIP v2 SSE events and REST responses recorded by the instrumented driver during physical testing, with every UUID, MAC, IP, bridge id and Zigbee PAN id replaced by a stable fake. Payloads are stored as their original JSON text and decoded on access. HueDeviceBuilder now only expects the init levelRange event for profiles that have switchLevel (so on/off plugs can be built), and exposes `driver_device` for reading or seeding a device's driver-side fields. Co-Authored-By: Claude Opus 5.5 --- .../src/test/fixtures/capture_payloads.lua | 82 +++++++++++++++++++ .../philips-hue/src/test/hue_test_helpers.lua | 29 +++++-- 2 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 drivers/SmartThings/philips-hue/src/test/fixtures/capture_payloads.lua 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 index 04523cd8f8..afb741a565 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -414,6 +414,16 @@ function HueDeviceBuilder:enable_sse() 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. --- @@ -607,12 +617,14 @@ function HueDeviceBuilder:start() end elseif child_config.type == "light" then - -- Lights always emit levelRange on init - test.socket.capability:__expect_send( - mock_child:generate_test_message("main", - capabilities.switchLevel.levelRange({ minimum = 1, maximum = 100 }) + -- 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 @@ -713,6 +725,12 @@ function HueDeviceBuilder:start() 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, @@ -720,6 +738,7 @@ function HueDeviceBuilder:start() test_init = test_init, configs = configs, seed_groups = seed_groups, + driver_device = driver_device, } end From 359bd6afcca85d75d38d9a51a774e2f564b72e10 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 10:25:19 -0500 Subject: [PATCH 06/19] philips-hue: Add tests replaying behavior recorded on real hardware Characterization tests built from the physical test captures: - setLevel 0 and out-of-range setColorTemperature values are clamped to the request bodies the bridge actually received. - A recorded LCT014 add event creates exactly one child device, a recorded smart plug removal deletes it, and recorded resource events the driver doesn't model (homekit, geolocation, behavior_script, ...) are ignored. - After a driver restart an onboarded light only receives init and is refreshed once, after the event stream's connectivity check. - With two bridges on the network, only the one that returns an API key gets a device. Co-Authored-By: Claude Opus 5.5 --- .../src/test/test_hue_bridge_discovery.lua | 51 +++++++ .../test/test_hue_bridge_resource_events.lua | 128 ++++++++++++++++++ .../src/test/test_hue_driver_restart.lua | 119 ++++++++++++++++ .../src/test/test_hue_light_commands.lua | 43 ++++++ 4 files changed, 341 insertions(+) create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_bridge_resource_events.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua 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 index 91e91a3c0b..0c783518ea 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua @@ -119,4 +119,55 @@ test.register_coroutine_test( 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..7d941e0184 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_resource_events.lua @@ -0,0 +1,128 @@ +--- 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") + -- `samsungim.hueSyncMode` (emitted for `mode`) has no definition available to the test framework. + resp.data[1].mode = nil + 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:__expect_send(mock_plug:generate_test_message("main", capabilities.switch.switch.on())) +end + +test.set_test_init_function(function() + 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, + }) + + 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")) + for _ = 1, 10 do + if mock_devices_api.__is_device_deleted(mock_plug.id) then break end + test.wait_for_events() + end + + assert(mock_devices_api.__is_device_deleted(mock_plug.id) == true, + "expected the plug to be 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_driver_restart.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua new file mode 100644 index 0000000000..206f544f7a --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua @@ -0,0 +1,119 @@ +--- 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") + -- `samsungim.hueSyncMode` (emitted for `mode`) has no definition available to the test framework. + resp.data[1].mode = nil + 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.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() + for _ = 1, 20 do + if mock_devices_api.__is_device_online(mock_lamp.id) then break end + test.wait_for_events() + end + 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_light_commands.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua index e9fc487900..d3a4c30d64 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua @@ -234,4 +234,47 @@ test.register_coroutine_test( 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() + + rest:assert_sent('"dimming":%s*{%s*"brightness":%s*1%s*}') + 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() + + rest:assert_sent('"color_temperature":%s*{%s*"mirek":%s*' .. case.mirek .. '%s*}') + rest:assert_sent('"on":%s*{%s*"on":%s*true%s*}') + end + ) +end + test.run_registered_tests() From d5b54e93aad2888139dde4b9c2970e7127af1d27 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 10:25:35 -0500 Subject: [PATCH 07/19] philips-hue: Keep a light's gamut and skip colour temperature in colour mode Two issues seen in the physical test captures after setColor: - In xy colour mode the bridge reports `mirek: null, mirek_valid: false`, and the driver fell back to the default mirek and emitted a 6500K colorTemperature event alongside the new hue/saturation. Skip the colour temperature event when mirek_valid is false. - SSE colour updates only carry `xy`, and the driver stored their missing gamut over the light's real one, so later setColor commands and colour reports used the default gamut. Only store a gamut that is present, and fall back to the stored one otherwise. The refresh path also stored `gamut_type` (e.g. "C") in the GAMUT field, which expects a gamut table; it now stores the table. The emitter overwrote it immediately, so this had no visible effect. Co-Authored-By: Claude Opus 5.5 --- .../src/handlers/attribute_emitters.lua | 54 ++++--- .../src/handlers/refresh_handlers.lua | 4 +- .../src/test/test_hue_light_color.lua | 137 ++++++++++++++++++ 3 files changed, 172 insertions(+), 23 deletions(-) create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua 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/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/test/test_hue_light_color.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua new file mode 100644 index 0000000000..1947e74f5d --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua @@ -0,0 +1,137 @@ +--- 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" + -- `samsungim.hueSyncMode` (emitted for `mode`) has no definition available to the test framework. + resp.data[1].mode = nil + 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.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() + rest:assert_sent('"x":%s*0%.2151[,}]') + rest:assert_sent('"y":%s*0%.7106[,}]') + end +) + +test.run_registered_tests() From eb14898c80d25f25d4786ec67b6c173b7e63afca Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 10:25:54 -0500 Subject: [PATCH 08/19] philips-hue: Only turn a group on before setLevel if one of its lights is off A grouped setLevel always sent a grouped_light `on` request before the brightness request, doubling grouped traffic (which the bridge rate-limits) for rooms that were already on; physical testing showed two grouped PUTs for every grouped setLevel. Check the lights' cached switch state first, as the individual setLevel handler does. Co-Authored-By: Claude Opus 5.5 --- .../src/handlers/grouped_light_commands.lua | 26 ++++++---- .../src/test/test_hue_batch_commands.lua | 50 +++++++++++++++++++ 2 files changed, 67 insertions(+), 9 deletions(-) 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/test/test_hue_batch_commands.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua index 1b4516993e..f7fafdc785 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua @@ -10,6 +10,7 @@ _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" @@ -208,4 +209,53 @@ test.register_coroutine_test( 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) + rest:assert_sent('"dimming":%s*{%s*"brightness":%s*23%s*}') + assert(not rest:get_sent_log():find('"on":', 1, true), "expected no on/off request for a room that is already on") + 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) + rest:assert_sent('"on":%s*{%s*"on":%s*true%s*}') + rest:assert_sent('"dimming":%s*{%s*"brightness":%s*23%s*}') + end +) + test.run_registered_tests() From 7e9c7e320f615eb2bc8219c90a8754f8f2139a83 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 10:26:12 -0500 Subject: [PATCH 09/19] philips-hue: Register a light's services when it initializes Light init built the device's service map (resource id -> type) but never stored it in driver.services_for_device_rid, which is what routes a zigbee_connectivity SSE update to the device that owns it. Until a bridge scan happened to fill the map in, connectivity changes for lights were dropped: a light losing its Zigbee connection stayed online. Store the map as the button and sensor init paths already do. Co-Authored-By: Claude Opus 5.5 --- .../src/handlers/lifecycle_handlers/light.lua | 3 + .../src/test/test_hue_device_connectivity.lua | 156 ++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua 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..fee6a47649 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua @@ -227,6 +227,9 @@ 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 })) 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..4d992913c9 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua @@ -0,0 +1,156 @@ +--- Tests for online/offline status from per-light Zigbee connectivity reported over SSE. 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") + -- `samsungim.hueSyncMode` (emitted for `mode`) has no definition available to the test framework. + resp.data[1].mode = nil + 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.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, so give the driver's threads a bounded number of turns. +local function wait_until(predicate) + for _ = 1, 50 do + if predicate() then return end + test.wait_for_events() + end +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 + +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.run_registered_tests() From 5d085697eefa0e48da16abb25f1bbb45d4fbd0ac Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 10:26:27 -0500 Subject: [PATCH 10/19] philips-hue: Mark devices offline once per event stream outage The EventSource calls onerror for every failed connection attempt, about every 3 seconds while the bridge is unreachable, and each call marked the bridge and every child offline again. During physical testing one outage produced 315 offline updates in about two and a half minutes (45 failed attempts x 7 devices). Report the outage once and re-arm when the stream reconnects. Co-Authored-By: Claude Opus 5.5 --- .../src/test/test_hue_device_connectivity.lua | 46 ++++++++++++++++++- .../src/utils/hue_bridge_utils.lua | 6 +++ 2 files changed, 50 insertions(+), 2 deletions(-) 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 index 4d992913c9..2889d44ebd 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua @@ -1,5 +1,6 @@ ---- Tests for online/offline status from per-light Zigbee connectivity reported over SSE. Built from ---- payloads recorded on real hardware (see test/fixtures/capture_payloads.lua). +--- 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" @@ -7,6 +8,7 @@ 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 LAMP_RID = capture_payloads.ids.color_lamp_light local LAMP_DEVICE_ID = capture_payloads.ids.color_lamp_device @@ -153,4 +155,44 @@ test.register_coroutine_test( 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") + + local offline_calls = {} + local device_offline = mock_devices_api.device_offline + mock_devices_api.device_offline = function(device) + local id = type(device) == "table" and device.id or device + offline_calls[id] = (offline_calls[id] or 0) + 1 + return device_offline(device) + end + local function assert_offline_calls(expected) + for _, entry in ipairs({ { mock_bridge, "bridge" }, { mock_lamp, "lamp" }, { mock_other, "other light" } }) do + local count = offline_calls[entry[1].id] or 0 + assert(count == expected, + string.format("expected the %s to be marked offline %d time(s), got %d", entry[2], expected, count)) + end + end + + -- The EventSource calls `onerror` once per failed connection attempt; while the bridge is + -- unreachable that is every few seconds (see scenarios 8 and 9 of the physical test captures). + -- The mock bridge can't refuse a reconnect, so drive the handler directly. + local eventsource = fixtures.driver_device(mock_bridge):get_field(Fields.EVENT_SOURCE) + for _ = 1, 3 do eventsource.onerror() end + assert_offline_calls(1) + assert_online(mock_bridge, false, "the bridge") + assert_online(mock_lamp, false, "the lamp") + + -- Once the stream reconnects, a later failure marks everything offline again. + eventsource.onopen() + wait_until(function() return is_online(mock_bridge) == true end) + eventsource.onerror() + assert_offline_calls(2) + + mock_devices_api.device_offline = device_offline + end +) + test.run_registered_tests() 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 c73f526127..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]] @@ -126,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) From 501a2a43645c677688d720e04d940fbd9eb01f66 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 10:26:46 -0500 Subject: [PATCH 11/19] philips-hue: Don't emit levelRange for on/off-only lights Light init always emitted switchLevel.levelRange, which an on/off smart plug's profile doesn't have; the hub dropped it with a "does not support capability Switch Level" warning on every plug init. Only emit it when the profile supports switchLevel, and add tests for a plug built from its recorded light and device resources. Co-Authored-By: Claude Opus 5.5 --- .../src/handlers/lifecycle_handlers/light.lua | 5 +- .../philips-hue/src/test/test_hue_plug.lua | 101 ++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua 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 fee6a47649..0e3f2ca848 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/lifecycle_handlers/light.lua @@ -231,7 +231,10 @@ function LightLifecycleHandlers.init(driver, device) 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/test/test_hue_plug.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua new file mode 100644 index 0000000000..b6e19b32db --- /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") + -- `samsungim.hueSyncMode` (emitted for `mode`) has no definition available to the test framework. + resp.data[1].mode = nil + 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({ put = true }) +local rest, put = conns.rest, conns.put_conn + +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 = {} }, + }) +end + +test.register_coroutine_test( + "refreshing a plug emits only its switch 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(put, "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() + + put:assert_sent('"on":%s*{%s*"on":%s*' .. tostring(command == "on") .. '%s*}') + end + ) +end + +test.run_registered_tests() From dd570d07c986d279f82ab8c292d6cd532f48c3ab Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 11:06:40 -0500 Subject: [PATCH 12/19] philips-hue: Test a real event stream outage with failed reconnects Replace the test that called the EventSource's onerror directly with one where the bridge drops the stream and refuses the next two reconnects ("No route to host", as recorded in physical test scenarios 8 and 9), using the integration test framework's new fail_connects. Co-Authored-By: Claude Opus 5.5 --- .../src/test/test_hue_device_connectivity.lua | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) 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 index 2889d44ebd..f443803275 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua @@ -8,7 +8,6 @@ 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 LAMP_RID = capture_payloads.ids.color_lamp_light local LAMP_DEVICE_ID = capture_payloads.ids.color_lamp_device @@ -176,20 +175,14 @@ test.register_coroutine_test( end end - -- The EventSource calls `onerror` once per failed connection attempt; while the bridge is - -- unreachable that is every few seconds (see scenarios 8 and 9 of the physical test captures). - -- The mock bridge can't refuse a reconnect, so drive the handler directly. - local eventsource = fixtures.driver_device(mock_bridge):get_field(Fields.EVENT_SOURCE) - for _ = 1, 3 do eventsource.onerror() end - assert_offline_calls(1) - assert_online(mock_bridge, false, "the bridge") - assert_online(mock_lamp, false, "the lamp") - - -- Once the stream reconnects, a later failure marks everything offline again. - eventsource.onopen() + -- 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) - eventsource.onerror() - assert_offline_calls(2) + assert_offline_calls(1) + assert_online(mock_bridge, true, "the bridge") mock_devices_api.device_offline = device_offline end From 35bf7161b3effe42bb2f1139944204fbe47562d5 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 11:08:55 -0500 Subject: [PATCH 13/19] philips-hue: Test resending a command after the bridge closes a keep-alive With the integration test framework now giving each connection its own mock entry, cover physical test scenario 6: a PUT sent on a connection the bridge had already closed is resent on a new connection. The outage test also checks that a second outage after reconnecting is reported again. Co-Authored-By: Claude Opus 5.5 --- .../src/test/test_hue_device_connectivity.lua | 6 +++ .../src/test/test_hue_error_handling.lua | 46 +++++++++++++++++++ 2 files changed, 52 insertions(+) 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 index f443803275..0f88f25365 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua @@ -184,6 +184,12 @@ test.register_coroutine_test( assert_offline_calls(1) assert_online(mock_bridge, true, "the bridge") + -- 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) + mock_devices_api.device_offline = device_offline end ) 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 index 1958885160..4f2b31b79f 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua @@ -272,4 +272,50 @@ test.register_coroutine_test( 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 function count_puts() + local _, n = put_conn:get_sent_log():gsub("PUT /clip/v2/resource/light/", "") + return n + end + for _ = 1, 50 do + if count_puts() >= 3 then break end + test.wait_for_events() + end + local connections = {} + for chunk in (put_conn:get_sent_log() .. "\n--- Next physical connection ---\n"):gmatch("(.-)\n%-%-%- Next physical connection %-%-%-\n") do + table.insert(connections, chunk) + end + assert(#connections == 2, string.format("expected a reconnect, got %d connection(s)", #connections)) + local _, on_new = connections[2]:gsub("PUT /clip/v2/resource/light/", "") + assert(on_new == 1, string.format("expected the command to be resent once on the new connection, got %d", on_new)) + end +) + test.run_registered_tests() From ed747e340bda10f144c1df10ec1c55c65549feb2 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 11:10:39 -0500 Subject: [PATCH 14/19] philips-hue: Test event stream reconnects rejected with 503 Using the integration test framework's new prepend/times expectations, cover the bridge answering the next two event stream reconnects with 503: devices are marked offline once and come back after the third attempt. The resource event tests now use a single-use SSE handshake, which the framework can deliver events after. Co-Authored-By: Claude Opus 5.5 --- .../test/test_hue_bridge_resource_events.lua | 3 +- .../src/test/test_hue_device_connectivity.lua | 58 ++++++++++++++----- 2 files changed, 44 insertions(+), 17 deletions(-) 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 index 7d941e0184..743fbfdcb7 100644 --- 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 @@ -43,7 +43,8 @@ local function expect_plug_refresh_events() end test.set_test_init_function(function() - http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + -- 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, 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 index 0f88f25365..dc213232b9 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua @@ -122,6 +122,25 @@ local function assert_online(mock_device, expected, what) string.format("expected %s to be %s", what, expected and "online" or "offline")) end +--- Count Device:offline() calls per device until the returned `stop` is called. +local function count_offline_calls() + local offline_calls = {} + local device_offline = mock_devices_api.device_offline + mock_devices_api.device_offline = function(device) + local id = type(device) == "table" and device.id or device + offline_calls[id] = (offline_calls[id] or 0) + 1 + return device_offline(device) + end + local function assert_offline_calls(expected) + for _, entry in ipairs({ { mock_bridge, "bridge" }, { mock_lamp, "lamp" }, { mock_other, "other light" } }) do + local count = offline_calls[entry[1].id] or 0 + assert(count == expected, + string.format("expected the %s to be marked offline %d time(s), got %d", entry[2], expected, count)) + end + end + return assert_offline_calls, function() mock_devices_api.device_offline = device_offline end +end + test.register_coroutine_test( "a Zigbee connectivity issue for one light marks only that light offline", function() @@ -160,21 +179,7 @@ test.register_coroutine_test( test.wait_for_events() assert_online(mock_bridge, true, "the bridge") - local offline_calls = {} - local device_offline = mock_devices_api.device_offline - mock_devices_api.device_offline = function(device) - local id = type(device) == "table" and device.id or device - offline_calls[id] = (offline_calls[id] or 0) + 1 - return device_offline(device) - end - local function assert_offline_calls(expected) - for _, entry in ipairs({ { mock_bridge, "bridge" }, { mock_lamp, "lamp" }, { mock_other, "other light" } }) do - local count = offline_calls[entry[1].id] or 0 - assert(count == expected, - string.format("expected the %s to be marked offline %d time(s), got %d", entry[2], expected, count)) - end - end - + local assert_offline_calls, stop_counting = count_offline_calls() -- 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") @@ -190,7 +195,28 @@ test.register_coroutine_test( assert_offline_calls(2) wait_until(function() return is_online(mock_bridge) == true end) - mock_devices_api.device_offline = device_offline + stop_counting() + end +) + +test.register_coroutine_test( + "while the bridge rejects event stream reconnects, devices are only marked offline once", + function() + test.wait_for_events() + local assert_offline_calls, stop_counting = count_offline_calls() + + -- 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)) + stop_counting() end ) From 0c0bf3fe19c507c7a039224c9875784b64ac278a Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 11:13:36 -0500 Subject: [PATCH 15/19] philips-hue: Replay recorded light resources with their sync mode Register the platform's samsungim.hueSyncMode definition (fetched from the SmartThings API) for all Hue tests, so recorded light resources no longer need their `mode` stripped and the resulting Hue sync mode events are checked. Co-Authored-By: Claude Opus 5.5 --- .../capabilities/samsungim.hueSyncMode.json | 30 +++++++++++++++++++ .../philips-hue/src/test/hue_test_helpers.lua | 4 +++ .../test/test_hue_bridge_resource_events.lua | 4 +-- .../src/test/test_hue_device_connectivity.lua | 3 +- .../src/test/test_hue_driver_restart.lua | 3 +- .../src/test/test_hue_light_color.lua | 3 +- .../philips-hue/src/test/test_hue_plug.lua | 6 ++-- 7 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 drivers/SmartThings/philips-hue/src/test/fixtures/capabilities/samsungim.hueSyncMode.json 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/hue_test_helpers.lua b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua index afb741a565..33a913002a 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -22,6 +22,10 @@ local HueApi = require "hue.api" --- --- 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" 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 index 743fbfdcb7..4c34bf64e5 100644 --- 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 @@ -13,8 +13,6 @@ local ids = capture_payloads.ids local function plug_light_resource() local resp = capture_payloads.get("plug_light") - -- `samsungim.hueSyncMode` (emitted for `mode`) has no definition available to the test framework. - resp.data[1].mode = nil return resp end @@ -39,6 +37,8 @@ http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. ids.plug_light, { }) 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 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 index dc213232b9..93ce205b8c 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua @@ -18,8 +18,6 @@ local OTHER_ZIGBEE_RID = "other-light-zigbee" local function lamp_light_resource() local resp = capture_payloads.get("color_lamp_light") - -- `samsungim.hueSyncMode` (emitted for `mode`) has no definition available to the test framework. - resp.data[1].mode = nil return resp end @@ -54,6 +52,7 @@ local function expect_lamp_state_events(first_refresh) 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), 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 index 206f544f7a..88314e0a19 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua @@ -15,8 +15,6 @@ local ids = capture_payloads.ids local function lamp_light_resource() local resp = capture_payloads.get("color_lamp_light") - -- `samsungim.hueSyncMode` (emitted for `mode`) has no definition available to the test framework. - resp.data[1].mode = nil return resp end @@ -87,6 +85,7 @@ test.set_test_init_function(function() 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), 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 index 1947e74f5d..0bed77925b 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua @@ -23,8 +23,6 @@ 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" - -- `samsungim.hueSyncMode` (emitted for `mode`) has no definition available to the test framework. - resp.data[1].mode = nil return resp end @@ -57,6 +55,7 @@ local function expect_light_state_events(first_refresh) 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), diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua index b6e19b32db..da16dacd14 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua @@ -13,8 +13,6 @@ local PLUG_ZIGBEE_RID = capture_payloads.ids.plug_zigbee local function plug_light_resource(on) local resp = capture_payloads.get("plug_light") - -- `samsungim.hueSyncMode` (emitted for `mode`) has no definition available to the test framework. - resp.data[1].mode = nil if on ~= nil then resp.data[1].on.on = on end return resp end @@ -46,10 +44,12 @@ local function refresh() 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 only its switch state", + "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, { From 720dac46f8700285e83df84af2f1ed38fda92bfb Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 11:17:17 -0500 Subject: [PATCH 16/19] philips-hue: Check request bodies as JSON in the newer tests Use the integration test framework's assert_sent_json and count_requests instead of pattern-matching raw request bytes, so the assertions no longer depend on JSON key order, and drop the batch tests' hand-written request counter. Co-Authored-By: Claude Opus 5.5 --- .../src/test/test_hue_batch_commands.lua | 34 +++++++------------ .../src/test/test_hue_light_color.lua | 4 +-- .../src/test/test_hue_light_commands.lua | 6 ++-- .../philips-hue/src/test/test_hue_plug.lua | 2 +- 4 files changed, 18 insertions(+), 28 deletions(-) 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 index f7fafdc785..ada8dbf96d 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua @@ -69,21 +69,9 @@ local function expect_grouped_light_put(rid) }) end ---- Count requests sent in this test whose request line targets `path` (plain-text match). -local function count_requests(method, path) - local sent = rest:get_sent_log() - local needle = method .. " " .. path - local count, start = 0, 1 - while true do - local i = sent:find(needle, start, true) - if not i then return count end - count = count + 1 - start = i + #needle - end -end - local function assert_requests(method, path, expected) - local actual = count_requests(method, path) + -- `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 @@ -107,7 +95,7 @@ test.register_coroutine_test( assert_requests("PUT", LIGHT_PATH .. LIGHT_1_RID, 1) assert_requests("PUT", GROUPED_LIGHT_PATH, 0) - rest:assert_sent('"on":%s*{%s*"on":%s*true%s*}') + http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_1_RID, { on = { on = true } }) end ) @@ -125,7 +113,7 @@ test.register_coroutine_test( assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 1) assert_requests("PUT", LIGHT_PATH, 0) - rest:assert_sent('"on":%s*{%s*"on":%s*true%s*}') + http.assert_sent_json(rest, "PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, { on = { on = true } }) end ) @@ -184,8 +172,8 @@ test.register_coroutine_test( 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('"on":%s*{%s*"on":%s*true%s*}') - rest:assert_sent('"on":%s*{%s*"on":%s*false%s*}') + 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 ) @@ -232,8 +220,10 @@ test.register_coroutine_test( test.wait_for_events() assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 1) - rest:assert_sent('"dimming":%s*{%s*"brightness":%s*23%s*}') - assert(not rest:get_sent_log():find('"on":', 1, true), "expected no on/off request for a room that is already on") + 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 ) @@ -253,8 +243,8 @@ test.register_coroutine_test( test.wait_for_events() assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 2) - rest:assert_sent('"on":%s*{%s*"on":%s*true%s*}') - rest:assert_sent('"dimming":%s*{%s*"brightness":%s*23%s*}') + 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 ) 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 index 0bed77925b..a230e341f7 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua @@ -128,8 +128,8 @@ test.register_coroutine_test( args = { { hue = 100 / 3, saturation = 100 } } }, }) test.wait_for_events() - rest:assert_sent('"x":%s*0%.2151[,}]') - rest:assert_sent('"y":%s*0%.7106[,}]') + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, + { color = { xy = { x = 0.2151, y = 0.7106 } } }) end ) 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 index d3a4c30d64..ebe4e12072 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua @@ -253,7 +253,7 @@ test.register_coroutine_test( }) test.wait_for_events() - rest:assert_sent('"dimming":%s*{%s*"brightness":%s*1%s*}') + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { dimming = { brightness = 1 } }) end ) @@ -271,8 +271,8 @@ for _, case in ipairs({ }) test.wait_for_events() - rest:assert_sent('"color_temperature":%s*{%s*"mirek":%s*' .. case.mirek .. '%s*}') - rest:assert_sent('"on":%s*{%s*"on":%s*true%s*}') + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, + { color_temperature = { mirek = case.mirek }, on = { on = true } }) end ) end diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua index da16dacd14..eb116593e0 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua @@ -93,7 +93,7 @@ for _, command in ipairs({ "on", "off" }) do }) test.wait_for_events() - put:assert_sent('"on":%s*{%s*"on":%s*' .. tostring(command == "on") .. '%s*}') + http.assert_sent_json(put, "PUT", "/clip/v2/resource/light/" .. PLUG_RID, { on = { on = command == "on" } }) end ) end From 6cf36a23ae2475fa36f93080bd3f9e5df2cb3bc8 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 11:18:40 -0500 Subject: [PATCH 17/19] philips-hue: Count online/offline calls with the framework's status history Replace the outage tests' monkeypatched device_offline with the integration test framework's device status history, and also check that the bridge is marked online exactly once when the stream reconnects. Co-Authored-By: Claude Opus 5.5 --- .../src/test/test_hue_device_connectivity.lua | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) 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 index 93ce205b8c..0fed44ec9b 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua @@ -121,23 +121,14 @@ local function assert_online(mock_device, expected, what) string.format("expected %s to be %s", what, expected and "online" or "offline")) end ---- Count Device:offline() calls per device until the returned `stop` is called. -local function count_offline_calls() - local offline_calls = {} - local device_offline = mock_devices_api.device_offline - mock_devices_api.device_offline = function(device) - local id = type(device) == "table" and device.id or device - offline_calls[id] = (offline_calls[id] or 0) + 1 - return device_offline(device) +--- 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 - local function assert_offline_calls(expected) - for _, entry in ipairs({ { mock_bridge, "bridge" }, { mock_lamp, "lamp" }, { mock_other, "other light" } }) do - local count = offline_calls[entry[1].id] or 0 - assert(count == expected, - string.format("expected the %s to be marked offline %d time(s), got %d", entry[2], expected, count)) - end - end - return assert_offline_calls, function() mock_devices_api.device_offline = device_offline end end test.register_coroutine_test( @@ -178,7 +169,7 @@ test.register_coroutine_test( test.wait_for_events() assert_online(mock_bridge, true, "the bridge") - local assert_offline_calls, stop_counting = count_offline_calls() + 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") @@ -187,6 +178,8 @@ test.register_coroutine_test( 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() @@ -194,7 +187,6 @@ test.register_coroutine_test( assert_offline_calls(2) wait_until(function() return is_online(mock_bridge) == true end) - stop_counting() end ) @@ -202,7 +194,7 @@ test.register_coroutine_test( "while the bridge rejects event stream reconnects, devices are only marked offline once", function() test.wait_for_events() - local assert_offline_calls, stop_counting = count_offline_calls() + 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. @@ -215,7 +207,6 @@ test.register_coroutine_test( 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)) - stop_counting() end ) From b75665d1de1e3e39368bde0754e7ed736681f19f Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 11:19:53 -0500 Subject: [PATCH 18/19] philips-hue: Use the framework's wait_until in tests Replace hand-rolled wait loops with integration_test.wait_until, which fails with a description of what didn't happen instead of falling through to a later assertion, and check the keep-alive resend by which connection each request went out on. Co-Authored-By: Claude Opus 5.5 --- .../test/test_hue_bridge_resource_events.lua | 9 ++----- .../src/test/test_hue_device_connectivity.lua | 9 +++---- .../src/test/test_hue_driver_restart.lua | 6 ++--- .../src/test/test_hue_error_handling.lua | 24 +++++++------------ 4 files changed, 16 insertions(+), 32 deletions(-) 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 index 4c34bf64e5..5b169a0598 100644 --- 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 @@ -116,13 +116,8 @@ test.register_coroutine_test( -- 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")) - for _ = 1, 10 do - if mock_devices_api.__is_device_deleted(mock_plug.id) then break end - test.wait_for_events() - end - - assert(mock_devices_api.__is_device_deleted(mock_plug.id) == true, - "expected the plug to be deleted after the bridge reported its light resource 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 ) 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 index 0fed44ec9b..87a3b50c90 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua @@ -103,13 +103,10 @@ test.set_test_init_function(function() 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, so give the driver's threads a bounded number of turns. +--- 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) - for _ = 1, 50 do - if predicate() then return end - test.wait_for_events() - end + test.wait_until(predicate, { message = "the expected online/offline state" }) end local function is_online(mock_device) 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 index 88314e0a19..d25e07337e 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua @@ -99,10 +99,8 @@ end) test.register_coroutine_test( "after a restart an onboarded light is initialized and refreshed once, without being re-added", function() - for _ = 1, 20 do - if mock_devices_api.__is_device_online(mock_lamp.id) then break end - test.wait_for_events() - end + 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") 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 index 4f2b31b79f..879e28f13d 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua @@ -300,21 +300,15 @@ test.register_coroutine_test( -- The second command went out on the closed connection, then again on a new one after the -- REST client's retry backoff. - local function count_puts() - local _, n = put_conn:get_sent_log():gsub("PUT /clip/v2/resource/light/", "") - return n - end - for _ = 1, 50 do - if count_puts() >= 3 then break end - test.wait_for_events() - end - local connections = {} - for chunk in (put_conn:get_sent_log() .. "\n--- Next physical connection ---\n"):gmatch("(.-)\n%-%-%- Next physical connection %-%-%-\n") do - table.insert(connections, chunk) - end - assert(#connections == 2, string.format("expected a reconnect, got %d connection(s)", #connections)) - local _, on_new = connections[2]:gsub("PUT /clip/v2/resource/light/", "") - assert(on_new == 1, string.format("expected the command to be resent once on the new connection, got %d", on_new)) + 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 ) From 153056692db66f8c0f632bf0c28d7a2738ff0c15 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Thu, 15 Oct 2026 11:20:53 -0500 Subject: [PATCH 19/19] philips-hue: Share one REST test connection for reads and commands create_hue_scenario's REST connection now accepts GET and PUT unless the test asks for a separate PUT connection, so the plug tests no longer need one to send commands. Co-Authored-By: Claude Opus 5.5 --- .../SmartThings/philips-hue/src/test/hue_test_helpers.lua | 7 +++++-- .../SmartThings/philips-hue/src/test/test_hue_plug.lua | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua index 33a913002a..06b41f3913 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -761,7 +761,8 @@ M.HueDeviceBuilder = { --- - port: Bridge port (default: 443) --- - rest: Include REST connection (default: true) --- - rest_name: Name for REST connection (default: "rest") ---- - rest_method: HTTP method for REST matcher (default: "GET") +--- - 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) @@ -782,8 +783,10 @@ function M.create_hue_scenario(options) -- 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(options.rest_method or "GET", "/clip/v2/resource/"), + matcher = http.matcher(rest_method, "/clip/v2/resource/"), ordering = options.rest_ordering or "relaxed" }) end diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua index eb116593e0..5e562717c9 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua @@ -28,8 +28,8 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() local mock_bridge, mock_plug = fixtures.bridge, fixtures.devices[1] -local scenario, conns = hue_test_helpers.create_hue_scenario({ put = true }) -local rest, put = conns.rest, conns.put_conn +local scenario, conns = hue_test_helpers.create_hue_scenario() +local rest = conns.rest hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) @@ -82,7 +82,7 @@ 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(put, "PUT", "/clip/v2/resource/light/" .. PLUG_RID, { + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. PLUG_RID, { status = 200, body = { data = { { rid = PLUG_RID, rtype = "light" } }, errors = {} }, }) @@ -93,7 +93,7 @@ for _, command in ipairs({ "on", "off" }) do }) test.wait_for_events() - http.assert_sent_json(put, "PUT", "/clip/v2/resource/light/" .. PLUG_RID, { on = { on = command == "on" } }) + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. PLUG_RID, { on = { on = command == "on" } }) end ) end