From 9a5395aa5a9befc21c6876c11fe4a6d0f9e78dc9 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 08:31:00 -0500 Subject: [PATCH 01/22] philips-hue: Add LAN integration tests Integration tests for the Hue driver running against mock bridge connections, using the LAN support in the integration test framework (connection_scenario and connection_scenario_http): - hue_test_helpers: HueDeviceBuilder builds a paired bridge and child devices (lights, buttons, motion and contact sensors, rooms) with their discovery cache state, and create_hue_scenario the bridge's REST and SSE connections, answering the driver's room and zone scans. Helpers register the requests each device type makes while initializing and build SSE event payloads. - Light commands (switch, level, colour, colour temperature, out-of-range values) and refresh, checked by the JSON bodies sent to the bridge. - Batched capability commands: a batch exactly covering a room becomes one grouped_light request; anything else goes out per light. - The event stream: connecting marks the bridge online, update/add/delete events reach the right devices, and a dropped stream marks everything offline until it reconnects; SSE events for buttons, multi-button remotes, and motion and contact sensors. - Error handling: error statuses, Hue API errors, missing services and unparseable responses emit nothing and don't stop later commands, and a command on a keep-alive connection the bridge closed is resent. - Bridge discovery over mDNS, and child devices with no cached state being left as strays. The samsungim.hueSyncMode definition (fetched from the SmartThings API) is registered for the tests, since the driver package doesn't ship it. Requires the LAN integration test support in the lua libs. Co-Authored-By: Claude Opus 5.5 --- .../capabilities/samsungim.hueSyncMode.json | 30 + .../philips-hue/src/test/hue_test_helpers.lua | 1488 +++++++++++++++++ .../src/test/test_hue_batch_commands.lua | 211 +++ .../src/test/test_hue_bridge_discovery.lua | 133 ++ .../src/test/test_hue_bridge_sse.lua | 233 +++ .../src/test/test_hue_button_lifecycle.lua | 38 + .../src/test/test_hue_button_sse.lua | 103 ++ .../test/test_hue_child_device_lifecycle.lua | 55 + .../src/test/test_hue_contact_sensor_sse.lua | 122 ++ .../src/test/test_hue_error_handling.lua | 188 +++ .../src/test/test_hue_light_commands.lua | 234 +++ .../src/test/test_hue_light_refresh.lua | 103 ++ .../src/test/test_hue_motion_sensor_sse.lua | 131 ++ .../src/test/test_hue_multibutton_sse.lua | 95 ++ 14 files changed, 3164 insertions(+) create mode 100644 drivers/SmartThings/philips-hue/src/test/fixtures/capabilities/samsungim.hueSyncMode.json create mode 100644 drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.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/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 new file mode 100644 index 0000000000..6a48534496 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -0,0 +1,1488 @@ +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local connection_scenario = require "integration_test.connection_scenario" +local http = require "integration_test.connection_scenario_http" +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. +--- +--- +--- `HueDeviceBuilder` builds the bridge and child devices; `create_hue_scenario` builds the +--- ConnectionScenario (REST and optionally SSE connections to the bridge) and the +--- `setup_*_init_expectations` helpers register what each device type requests while it +--- initializes. SSE event builders (`button_event`, `motion_event`, ...) build the payloads to +--- queue with `connection_scenario_http.queue_sse_event`. +-- `samsungim.hueSyncMode` is defined by the platform, not the driver package; its definition +-- (fetched from the SmartThings API) lets the driver emit Hue sync mode events in tests. +test.add_capability_definition("fixtures/capabilities/samsungim.hueSyncMode.json") + +local M = {} + +M.BRIDGE_IP = "192.168.1.15" +M.BRIDGE_DNI = "AA:BB:CC:DD:EE:FF" +M.API_KEY = "test-api-key" + + +--- `LightLifecycleHandlers.init` unconditionally emits a levelRange event on every light's +--- first init, regardless of bridge/pairing state. HueDeviceBuilder's `test_init` already +--- calls this; only call it directly if building a fixture by hand. +--- +--- Must be called from `test_init` (synchronous setup, before `require "init"` runs) rather +--- than from within a coroutine test body for proper event expectation timing. +--- +--- @param mock_light table +function M.expect_light_init_events(mock_light) + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switchLevel.levelRange({ minimum = 1, maximum = 100 })) + ) +end + +--- Refresh (and other per-device flows) check that the bridge has finished initializing via +--- Fields._INIT, which is normally set inside do_bridge_network_init once bridge setup fully +--- completes -- the same step that creates the bridge's SSE EventSource. Since SSE connects to +--- the same host:port as REST calls, and the mock LAN socket models one connection per address, +--- letting do_bridge_network_init run for real would interleave the SSE connection's own bytes +--- into REST-focused assertions. Call this from within a test body (after the automatic +--- added/init lifecycle burst has already run -- i.e. as the first statement in the test, not +--- from test_init) to mark the bridge initialized directly instead. +--- +--- @param mock_bridge table +function M.mark_bridge_initialized(mock_bridge) + mock_bridge:set_field(Fields._INIT, true, {}) +end + + +--- @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, + }) + return self +end + +--- Add a button device to the fixture. +--- +--- @param rid string Hue resource ID for the first button +--- @param config table configuration with: +--- - num_buttons: number of buttons (default 1) +--- - battery: battery level (default 85) +--- - label: device label (default "Hue Button") +--- - device_id: device ID (default: rid .. "-device") +--- - power_rid: power resource ID (default: rid .. "-power") +--- - button_rids: array of button RIDs (default: {rid, ...}) +--- @param profile string|nil profile filename (auto-selected based on num_buttons) +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:with_button(rid, config, profile) + config = config or {} + local num_buttons = config.num_buttons or 1 + local device_id = config.device_id or (rid .. "-device") + local power_rid = config.power_rid or (rid .. "-power") + + -- Build button RIDs array + local button_rids = config.button_rids or {rid} + if #button_rids < num_buttons then + for i = #button_rids + 1, num_buttons do + table.insert(button_rids, rid .. "-button" .. i) + end + end + + -- Auto-select profile based on number of buttons + if not profile then + if num_buttons == 1 then + profile = "single-button.yml" + elseif num_buttons == 4 then + profile = "4-button-remote.yml" + else + profile = "single-button.yml" -- fallback + end + end + + -- Build state table + local state = { + id = rid, + hue_provided_name = config.label or "Hue Button", + hue_device_id = device_id, + num_buttons = num_buttons, + power_state = { battery_level = config.battery or 85 }, + power_id = power_rid, + } + + -- Add button-specific fields + for i = 1, num_buttons do + state["button" .. i] = { + event_values = config.event_values or { "short_release", "long_press", "long_release" } + } + state["button" .. i .. "_id"] = button_rids[i] + end + + table.insert(self.children, { + type = "button", + rid = rid, + profile = profile, + state = state, + num_buttons = num_buttons, + battery = config.battery or 85, -- Store for later use in init_expectations + -- init_expectations will be created in start() based on sse_enabled + }) + return self +end + +--- Add a motion sensor to the fixture. +--- +--- @param rid string Hue resource ID for the motion sensor +--- @param config table|nil configuration with: +--- - battery: battery level (default 85) +--- - motion: initial motion state (default false) +--- - temperature: temperature in Celsius (default 20.0) +--- - light_level: light level (default 30000) +--- - label: device label (default "Hue Motion Sensor") +--- - device_id: device ID (default: rid .. "-device") +--- - power_rid: power resource ID (default: rid .. "-power") +--- - temperature_rid: temperature resource ID (default: rid .. "-temp") +--- - light_level_rid: light level resource ID (default: rid .. "-light") +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:with_motion(rid, config) + config = config or {} + local device_id = config.device_id or (rid .. "-device") + local power_rid = config.power_rid or (rid .. "-power") + local temperature_rid = config.temperature_rid or (rid .. "-temp") + local light_level_rid = config.light_level_rid or (rid .. "-light") + + -- Build discovery cache state for the motion sensor + local state = { + id = rid, + hue_provided_name = config.label or "Hue Motion Sensor", + hue_device_id = device_id, + motion = { motion = config.motion or false, motion_valid = true }, + motion_enabled = true, + temperature = { temperature = config.temperature or 20.0, temperature_valid = true }, + temperature_id = temperature_rid, + temperature_enabled = true, + light = { light_level = config.light_level or 30000, light_level_valid = true }, + light_level_id = light_level_rid, + light_level_enabled = true, + power_state = { battery_level = config.battery or 85 }, + power_id = power_rid, + sensor_list = { + id = "motion", + power_id = "device_power", + temperature_id = "temperature", + light_level_id = "light_level" + } + } + + table.insert(self.children, { + type = "motion", + rid = rid, + profile = "motion-sensor.yml", + state = state, + battery = config.battery or 85, + motion = config.motion or false, + temperature = config.temperature or 20.0, + light_level = config.light_level or 30000, + -- init_expectations will be created in start() based on sse_enabled + }) + return self +end + +--- Add a contact sensor to the fixture. +--- +--- @param rid string Hue resource ID for the contact sensor +--- @param config table|nil configuration with: +--- - battery: battery level (default 85) +--- - contact_state: initial contact state "contact"=closed, "no_contact"=open (default "contact") +--- - tamper: tamper state (default "not_tampered") +--- - label: device label (default "Hue Contact Sensor") +--- - device_id: device ID (default: rid .. "-device") +--- - power_rid: power resource ID (default: rid .. "-power") +--- - tamper_rid: tamper resource ID (default: rid .. "-tamper") +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:with_contact(rid, config) + config = config or {} + local device_id = config.device_id or (rid .. "-device") + local power_rid = config.power_rid or (rid .. "-power") + local tamper_rid = config.tamper_rid or (rid .. "-tamper") + + -- Build discovery cache state for the contact sensor + local state = { + id = rid, + hue_provided_name = config.label or "Hue Contact Sensor", + hue_device_id = device_id, + contact_report = { state = config.contact_state or "contact" }, -- "contact" = closed, "no_contact" = open + contact_enabled = true, + tamper_reports = { { state = config.tamper or "not_tampered" } }, + tamper_id = tamper_rid, + power_state = { battery_level = config.battery or 85 }, + power_id = power_rid, + sensor_list = { + id = "contact", + power_id = "device_power", + tamper_id = "tamper" + } + } + + table.insert(self.children, { + type = "contact", + rid = rid, + profile = "contact-sensor.yml", + state = state, + battery = config.battery or 85, + contact_state = config.contact_state or "contact", + tamper = config.tamper or "not_tampered", + -- init_expectations will be created in start() based on sse_enabled + }) + return self +end + +--- Add a Hue room containing some of this fixture's lights. +--- +--- The driver only scans the bridge's rooms after its event stream connects, so for a fixture +--- without SSE they are applied after init by calling `fixtures.seed_groups()` from within a +--- test, which feeds Hue room resources through the driver's own `grouped_utils.group_add`. +--- +--- @param rid string Hue resource ID for the room +--- @param grouped_light_rid string resource ID of the room's grouped_light service +--- @param light_rids string[] resource IDs of lights (added with `with_light`) in the room +--- @param name string|nil room name (default "Test Room") +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:with_room(rid, grouped_light_rid, light_rids, name) + self.rooms = self.rooms or {} + table.insert(self.rooms, { + rid = rid, + grouped_light_rid = grouped_light_rid, + light_rids = light_rids, + name = name or "Test Room", + }) + return self +end + +--- Enable SSE support for this fixture. +--- +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:enable_sse() + self.sse_enabled = true + return self +end + +--- Whether a profile file declares a capability on any component. +local function profile_has_capability(profile_filename, capability_id) + for _, component in pairs(t_utils.get_profile_definition(profile_filename).components or {}) do + for _, capability in pairs(component.capabilities or {}) do + if capability.id == capability_id then return true end + end + end + return false +end + +--- Build the fixture and return a structured fixtures object. +--- This creates all mock devices and returns them with their configurations. +--- +--- @return table fixtures with fields: +--- - bridge: mock bridge device +--- - devices: array of mock child devices +--- - test_init: function to pass to test.set_test_init_function +--- - configs: table of device configs by type (button, light, motion, contact) +function HueDeviceBuilder:start() + local mock_bridge = test.mock_device.build_test_lan_device({ + label = "Hue Bridge", + profile = t_utils.get_profile_definition("hue-bridge.yml"), + device_network_id = self.bridge_dni, + }) + + local mock_children = {} + for _, child_config in ipairs(self.children) do + local child_template = { + label = child_config.state.hue_provided_name, + profile = t_utils.get_profile_definition(child_config.profile), + parent_assigned_child_key = child_config.type .. ":" .. child_config.rid, + parent_device_id = mock_bridge.id, + } + table.insert(mock_children, test.mock_device.build_test_lan_device(child_template)) + end + + local driver_under_test = nil + test.add_test_env_setup_func(function(driver) + driver_under_test = driver + driver.datastore.bridge_netinfo = driver.datastore.bridge_netinfo or {} + if self.sse_enabled then + driver.datastore.bridge_netinfo[self.bridge_dni] = { + ip = self.bridge_ip, + swversion = tostring(HueApi.MIN_CLIP_V2_SWVERSION), + modelid = "BSB002" + } + driver.joined_bridges[self.bridge_dni] = true + else + driver.datastore.bridge_netinfo[self.bridge_dni] = { + ip = self.bridge_ip, + swversion = "0", + modelid = "BSB002" + } + end + driver.datastore.api_keys = driver.datastore.api_keys or {} + driver.datastore.api_keys[self.bridge_dni] = self.api_key + + local disco = require "disco" + disco.disco_api_instances = {} + disco.discovery_active = self.sse_enabled or false + + -- Populate disco cache with child device states + for i, child_config in ipairs(self.children) do + child_config.state.parent_device_id = mock_bridge.id + disco.device_state_disco_cache[child_config.rid] = child_config.state + end + end) + + local function test_init() + test.set_test_coroutine_priority(true) + + test.mock_device.add_test_device(mock_bridge) + for _, mock_child in ipairs(mock_children) do + test.mock_device.add_test_device(mock_child) + end + + mock_bridge:set_field(Fields.DEVICE_TYPE, "bridge", {}) + mock_bridge:set_field(Fields.BRIDGE_ID, self.bridge_dni, {}) + mock_bridge:set_field(Fields.IPV4, self.bridge_ip, {}) + mock_bridge:set_field(HueApi.APPLICATION_KEY_HEADER, self.api_key, {}) + + -- Check if we have any non-light children (buttons, sensors, etc.) + -- These need the bridge marked as _ADDED to avoid being treated as stray devices + local has_non_light_children = false + for _, child_config in ipairs(self.children) do + if child_config.type ~= "light" then + has_non_light_children = true + break + end + end + + if has_non_light_children then + mock_bridge:set_field(Fields._ADDED, true, { persist = true }) + -- Don't mark _INIT yet if SSE is enabled - let do_bridge_network_init run to set up SSE + if not self.sse_enabled then + mock_bridge:set_field(Fields._INIT, true, { persist = true }) + end + end + + -- Register init expectations for all children + -- Generate init_expectations based on device type and SSE status + for i, child_config in ipairs(self.children) do + local mock_child = mock_children[i] + + if child_config.type == "button" then + -- Button devices emit supportedButtonValues for each component + local components = {"main"} + for j = 2, child_config.num_buttons do + table.insert(components, "button" .. j) + end + + for _, component in ipairs(components) do + test.socket.capability:__expect_send( + mock_child:generate_test_message(component, + capabilities.button.supportedButtonValues( + { "pushed", "held" }, + { visibility = { displayed = false } } + ) + ) + ) + end + + -- Battery event from refresh during init (only if SSE is enabled) + if self.sse_enabled then + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.battery.battery(child_config.battery) + ) + ) + end + + elseif child_config.type == "motion" then + -- Motion sensors emit battery event from refresh during init (only if SSE enabled) + if self.sse_enabled then + test.socket.capability:__set_channel_ordering("relaxed") + + -- Motion state + local motion_value = child_config.motion and "active" or "inactive" + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.motionSensor.motion[motion_value]() + ) + ) + + -- Temperature + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.temperatureMeasurement.temperature({ + value = child_config.temperature, + unit = "C" + }) + ) + ) + + -- Illuminance (convert light_level to lux: lux = round(10^((light_level - 1) / 10000))) + -- Note: round() is math.floor(val + 0.5) to match st.utils.round + local lux = math.floor(10 ^ ((child_config.light_level - 1) / 10000) + 0.5) + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.illuminanceMeasurement.illuminance(lux) + ) + ) + + -- Battery + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.battery.battery(child_config.battery) + ) + ) + end + + elseif child_config.type == "contact" then + -- Contact sensors emit multiple events from refresh during init (only if SSE enabled) + if self.sse_enabled then + test.socket.capability:__set_channel_ordering("relaxed") + + -- Contact state + local contact_value = (child_config.contact_state == "no_contact") and "open" or "closed" + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.contactSensor.contact[contact_value]() + ) + ) + + -- Tamper state + local tamper_value = (child_config.tamper == "tampered") and "detected" or "clear" + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.tamperAlert.tamper[tamper_value]() + ) + ) + + -- Battery + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.battery.battery(child_config.battery) + ) + ) + end + + elseif child_config.type == "light" then + -- Light init emits levelRange; for a profile without switchLevel (e.g. a smart plug) the + -- device drops the event, so nothing is sent. + if profile_has_capability(child_config.profile, capabilities.switchLevel.ID) then + test.socket.capability:__expect_send( + mock_child:generate_test_message("main", + capabilities.switchLevel.levelRange({ minimum = 1, maximum = 100 }) + ) + ) + end + end + end + end + + -- Build configuration objects for each device type + local configs = {} + for i, child_config in ipairs(self.children) do + local device_key = child_config.type + if not configs[device_key] then + configs[device_key] = {} + end + + -- Build config based on device type + if child_config.type == "button" then + table.insert(configs[device_key], { + rid = child_config.rid, + device_id = child_config.state.hue_device_id, + label = child_config.state.hue_provided_name, + num_buttons = child_config.num_buttons, + battery = child_config.battery, + power_rid = child_config.state.power_id, + zigbee_rid = child_config.state.zigbee_connectivity_id or (child_config.rid .. "-zigbee"), + button_rids = {} -- Will be populated if needed + }) + -- Add button RIDs for multi-button devices + for j = 1, child_config.num_buttons do + table.insert(configs[device_key][#configs[device_key]].button_rids, child_config.state["button" .. j .. "_id"]) + end + + elseif child_config.type == "light" then + table.insert(configs[device_key], { + rid = child_config.rid, + device_id = child_config.state.hue_device_id, + label = child_config.state.hue_provided_name, + zigbee_rid = child_config.state.zigbee_connectivity_id or (child_config.rid .. "-zigbee"), + on = child_config.state.on, + dimming = child_config.state.dimming, + color = child_config.state.color, + color_temperature = child_config.state.color_temperature, + mode = child_config.state.mode + }) + + elseif child_config.type == "motion" then + table.insert(configs[device_key], { + rid = child_config.rid, + device_id = child_config.state.hue_device_id, + label = child_config.state.hue_provided_name, + battery = child_config.battery, + motion = child_config.motion, + temperature = child_config.temperature, + light_level = child_config.light_level, + power_rid = child_config.state.power_id, + zigbee_rid = child_config.state.zigbee_connectivity_id or (child_config.rid .. "-zigbee"), + temperature_rid = child_config.state.temperature_id, + light_level_rid = child_config.state.light_level_id + }) + + elseif child_config.type == "contact" then + table.insert(configs[device_key], { + rid = child_config.rid, + device_id = child_config.state.hue_device_id, + label = child_config.state.hue_provided_name, + battery = child_config.battery, + contact_state = child_config.contact_state, + tamper = child_config.tamper, + power_rid = child_config.state.power_id, + zigbee_rid = child_config.state.zigbee_connectivity_id or (child_config.rid .. "-zigbee"), + tamper_rid = child_config.state.tamper_id + }) + end + end + + local hue_device_id_by_rid = {} + for _, child_config in ipairs(self.children) do + hue_device_id_by_rid[child_config.rid] = child_config.state.hue_device_id + end + + -- The rooms declared with `with_room`, as the bridge's room resources. + local room_resources = {} + 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 + table.insert(room_resources, { + id = room.rid, + type = "room", + metadata = { name = room.name }, + children = children, + services = { { rid = room.grouped_light_rid, rtype = "grouped_light" } }, + }) + 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(room_resources) do + grouped_utils.group_add(driver_under_test, bridge_device, room) + end + return bridge_device:get_field(Fields.GROUPS) + end + + --- The driver's own device object for a mock device, e.g. to inspect or seed device fields. + --- Must be called from within a test. + local function driver_device(mock_device) + return driver_under_test:get_device_info(mock_device.id) + end + + -- Return structured fixtures object + return { + bridge = mock_bridge, + devices = mock_children, + test_init = test_init, + configs = configs, + rooms = room_resources, + seed_groups = seed_groups, + driver_device = driver_device, + } +end + +-- Export HueDeviceBuilder via a constructor function +M.HueDeviceBuilder = { + new = M.HueDeviceBuilder_new +} + +--- ConnectionScenario 2.0 Test Helpers +--- These helpers reduce boilerplate when using the new connection_scenario framework + +--- Create a ConnectionScenario configured for Hue bridge testing. +--- +--- @param options table|nil Configuration options: +--- - host: Bridge IP (default: hue_test_helpers.BRIDGE_IP) +--- - port: Bridge port (default: 443) +--- - rooms: Hue room resources the bridge reports to the driver's group scans (default: none), +--- e.g. `fixtures.rooms` from a HueDeviceBuilder with `with_room` +--- - sse: Include the SSE event stream connection (default: false) +--- @return table scenario The ConnectionScenario instance +--- @return table connections `{ rest = ..., sse = ... }` +function M.create_hue_scenario(options) + options = options or {} + + local scenario = connection_scenario.new({ + host = options.host or M.BRIDGE_IP, + port = options.port or 443 + }) + + local connections = {} + connections.rest = scenario:connection("rest", { + matcher = http.matcher({ "GET", "PUT" }, "^/clip/v2/resource/"), + ordering = "relaxed" + }) + -- The driver scans the bridge's rooms and zones (debounced) after lights are added and after + -- the event stream connects. + http.expect_request(connections.rest, "GET", "/clip/v2/resource/room", { + body = { errors = {}, data = options.rooms or {} }, + reusable = true, + }) + http.expect_request(connections.rest, "GET", "/clip/v2/resource/zone", { + body = { errors = {}, data = {} }, + reusable = true, + }) + if options.sse then + connections.sse = scenario:connection("sse", { + matcher = http.matcher("GET", "^/eventstream/clip/v2") + }) + 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 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 {} + 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 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 {} + 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 {} + 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 {} + 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 {} + 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 {} + 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 {} + 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 {} + 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 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 + +--- Assert that the bridge's event stream connected: the bridge is marked online once it does, the +--- handshake went out on the SSE connection, and every request expected while the devices +--- initialize (the scenario's single-use expectations) was made. +--- +--- @param scenario table The ConnectionScenario instance +--- @param mock_bridge table +function M.assert_event_stream_connected(scenario, mock_bridge) + test.wait_until(function() return test.mock_devices_api.__is_device_online(mock_bridge.id) == true end, + { message = "the bridge being marked online when its event stream connects" }) + scenario:get_connection("sse"):assert_sent("^GET /eventstream/clip/v2 HTTP/1.1\r\n") + scenario:assert_all_satisfied() +end + +--- Setup SSE connection expectations: the handshake and the connectivity scan the driver makes +--- once the stream connects. +--- +--- @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 {} + -- SSE handshake + http.expect_sse_handshake(sse_connection, "/eventstream/clip/v2", options.handshake_reusable ~= false) + + -- Connectivity poll after SSE opens + http.expect_request(rest_connection, "GET", "/clip/v2/resource/zigbee_connectivity", { + status = 200, + body = { + errors = {}, + data = {{ type = "zigbee_connectivity", status = "connected" }} + }, + reusable = options.poll_reusable + }) +end + +--- SSE Event Builders +--- These helpers create properly structured SSE event tables + +--- Create a button SSE event. +--- +--- @param button_rid string The button resource ID +--- @param event_type string Event type: "short_release", "long_press", "long_release", etc. +--- @param options table|nil Options: +--- - timestamp: Event timestamp (default: "2024-01-01T12:00:00Z") +--- - battery_level: Include battery level in event +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.button_event(button_rid, event_type, options) + options = options or {} + + local button_data = { + type = "button", + id = button_rid, + button = { + button_report = { + event = event_type, + updated = options.timestamp or "2024-01-01T12:00:00Z" + } + } + } + + if options.battery_level then + button_data.power_state = { battery_level = options.battery_level } + end + + return { + type = options.update_type or "update", + data = { button_data } + } +end + +--- Create a motion sensor SSE event. +--- +--- @param motion_rid string The motion sensor resource ID +--- @param is_active boolean Motion detected state +--- @param options table|nil Options: +--- - motion_valid: Motion valid flag (default: true) +--- - battery_level: Include battery level in event +--- - temperature: Include temperature in event +--- - light_level: Include light level in event +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.motion_event(motion_rid, is_active, options) + options = options or {} + + local motion_data = { + type = "motion", + id = motion_rid, + motion = { + motion = is_active, + motion_valid = options.motion_valid ~= false + } + } + + if options.battery_level then + motion_data.power_state = { battery_level = options.battery_level } + end + + if options.temperature then + motion_data.temperature = { + temperature = options.temperature, + temperature_valid = true + } + end + + if options.light_level then + motion_data.light = { + light_level = options.light_level, + light_level_valid = true + } + end + + return { + type = options.update_type or "update", + data = { motion_data } + } +end + +--- Create a contact sensor SSE event. +--- +--- @param contact_rid string The contact sensor resource ID +--- @param state string Contact state: "contact" (closed) or "no_contact" (open) +--- @param options table|nil Options: +--- - battery_level: Include battery level in event +--- - tamper_state: Include tamper state in event +--- - temperature: Include temperature in event +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.contact_event(contact_rid, state, options) + options = options or {} + + local contact_data = { + type = "contact", + id = contact_rid, + contact_report = { state = state } + } + + if options.battery_level then + contact_data.power_state = { battery_level = options.battery_level } + end + + if options.tamper_state then + contact_data.tamper_reports = {{ state = options.tamper_state }} + end + + if options.temperature then + contact_data.temperature = { + temperature = options.temperature, + temperature_valid = true + } + end + + return { + type = options.update_type or "update", + data = { contact_data } + } +end + +--- Create a tamper sensor SSE event. +--- +--- @param tamper_rid string The tamper sensor resource ID +--- @param state string Tamper state: "tampered" or "not_tampered" +--- @param options table|nil Options: +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.tamper_event(tamper_rid, state, options) + options = options or {} + + return { + type = options.update_type or "update", + data = {{ + type = "tamper", + id = tamper_rid, + tamper_reports = {{ state = state }} + }} + } +end + +--- Create a light SSE event. +--- +--- @param light_rid string The light resource ID +--- @param on_state boolean Light on/off state +--- @param brightness number|nil Brightness level (0-100) +--- @param options table|nil Options: +--- - color: Color object with xy coordinates +--- - color_temperature: Color temperature object with mirek +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.light_event(light_rid, on_state, brightness, options) + options = options or {} + + local light_data = { + type = "light", + id = light_rid, + on = { on = on_state } + } + + if brightness then + light_data.dimming = { brightness = brightness } + end + + if options.color then + light_data.color = options.color + end + + if options.color_temperature then + light_data.color_temperature = options.color_temperature + end + + return { + type = options.update_type or "update", + data = { light_data } + } +end + +--- Create a temperature sensor SSE event. +--- +--- @param temperature_rid string The temperature sensor resource ID +--- @param temperature number Temperature in Celsius +--- @param options table|nil Options: +--- - temperature_valid: Temperature valid flag (default: true) +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.temperature_event(temperature_rid, temperature, options) + options = options or {} + + return { + type = options.update_type or "update", + data = {{ + type = "temperature", + id = temperature_rid, + temperature = { + temperature = temperature, + temperature_valid = options.temperature_valid ~= false + } + }} + } +end + +--- Create a light level sensor SSE event. +--- +--- @param light_level_rid string The light level sensor resource ID +--- @param light_level number Light level value +--- @param options table|nil Options: +--- - light_level_valid: Light level valid flag (default: true) +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.light_level_event(light_level_rid, light_level, options) + options = options or {} + + return { + type = options.update_type or "update", + data = {{ + type = "light_level", + id = light_level_rid, + light = { + light_level = light_level, + light_level_valid = options.light_level_valid ~= false + } + }} + } +end + +--- Helper to escape a Hue UUID for use in Lua pattern matching. +--- Converts: "11111111-1111-1111-1111-111111111111" +--- To: "11111111%-1111%-1111%-1111%-111111111111" +--- +--- @param uuid string The UUID to escape +--- @return string Escaped UUID suitable for Lua patterns +function M.escape_uuid(uuid) + return uuid:gsub("%-", "%%-") +end + +--- Device-Type-Specific Init Expectation Setup Helpers +--- These high-level helpers configure all standard init-time expectations for a device type. + +--- Setup standard init-time expectations for a button device. +--- +--- This configures expectations for: +--- - Device info query +--- - Zigbee connectivity query +--- - Button resource queries (one per button) +--- - Device power/battery query +--- - SSE handshake (if sse_connection provided) +--- +--- @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 {} + -- 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 + +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) +--- +--- @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 {} + -- 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 + +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) +--- +--- @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 {} + -- 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 + +end + +return M diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua new file mode 100644 index 0000000000..606ea0927e --- /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({ rooms = fixtures.rooms }) +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/" + +--- Expect a PUT for a light, optionally requiring its JSON body to contain `json`. +local function expect_light_put(rid, json) + http.expect_request_for_test(rest, "PUT", LIGHT_PATH .. rid, { + request_json = json, + status = 200, + body = { data = { { rid = rid, rtype = "light" } } }, + }) +end + +--- Expect a PUT for a grouped_light, optionally requiring its JSON body to contain `json`. +local function expect_grouped_light_put(rid, json) + http.expect_request_for_test(rest, "PUT", GROUPED_LIGHT_PATH .. rid, { + request_json = json, + status = 200, + body = { data = { { rid = rid, rtype = "grouped_light" } } }, + }) +end + +local function assert_requests(method, path, expected) + -- `path` is either a full resource path or a collection prefix like LIGHT_PATH. + local actual = http.count_requests(rest, method, path, { prefix = true }) + assert(actual == expected, + string.format("expected %d %s %s request(s), got %d", expected, method, path, actual)) +end + +--- Register a test that also fails if any request it expected was never sent. +local function register_batch_test(name, body) + test.register_coroutine_test(name, function() + body() + rest:assert_satisfied({ test_scoped_only = true }) + end) +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 + +register_batch_test( + "a batch of one command is sent as a single light request", + function() + init_and_seed_groups() + expect_light_put(LIGHT_1_RID) + + test.socket.capability:__queue_receive_batch({ { light_1.id, switch_cmd("on") } }) + test.wait_for_events() + + assert_requests("PUT", LIGHT_PATH .. LIGHT_1_RID, 1) + assert_requests("PUT", GROUPED_LIGHT_PATH, 0) + http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_1_RID, { on = { on = true } }) + end +) + +register_batch_test( + "a batch covering every light in a room is sent as one grouped_light request", + function() + init_and_seed_groups() + expect_grouped_light_put(GROUPED_LIGHT_RID) + + test.socket.capability:__queue_receive_batch({ + { light_1.id, switch_cmd("on") }, + { light_2.id, switch_cmd("on") }, + }) + test.wait_for_events() + + assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 1) + assert_requests("PUT", LIGHT_PATH, 0) + http.assert_sent_json(rest, "PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, { on = { on = true } }) + end +) + +register_batch_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 +) + +register_batch_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 +) + +register_batch_test( + "commands with different arguments are not grouped", + function() + init_and_seed_groups() + expect_light_put(LIGHT_1_RID) + expect_light_put(LIGHT_2_RID) + + test.socket.capability:__queue_receive_batch({ + { light_1.id, switch_cmd("on") }, + { light_2.id, switch_cmd("off") }, + }) + test.wait_for_events() + + assert_requests("PUT", LIGHT_PATH .. LIGHT_1_RID, 1) + assert_requests("PUT", LIGHT_PATH .. LIGHT_2_RID, 1) + assert_requests("PUT", GROUPED_LIGHT_PATH, 0) + http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_1_RID, { on = { on = true } }) + http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_2_RID, { on = { on = false } }) + end +) + +register_batch_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) + http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_1_RID, { dimming_delta = { action = "up", brightness_delta = 10 } }) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua new file mode 100644 index 0000000000..ec7740cdb6 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua @@ -0,0 +1,133 @@ +local test = require "integration_test" +local connection_scenario = require "integration_test.connection_scenario" +local http = require "integration_test.connection_scenario_http" +local mock_mdns = test.mock_mdns +local mock_devices_api = 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. Discovery rescans every second until stopped, so let the test act as + -- soon as the driver waits rather than only once mock time has run far ahead. + test.set_test_coroutine_priority(true) +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 + +--- A bridge at `ip` answering the config request and the API key requests discovery makes (it +--- keeps asking for a key until one is handed out or discovery stops). +--- @return table scenario, already activated +--- @return table api the connection discovery's requests are made on +local function bridge_at(ip, config, key_response) + local scenario = connection_scenario.new({ host = ip, port = 443 }) + local api = scenario:connection("api", { matcher = http.matcher({ "GET", "POST" }, "^/api"), ordering = "relaxed" }) + http.expect_request(api, "GET", "/api/config", { body = config }) + http.expect_request(api, "POST", "/api", { + request_json = { devicetype = "smartthings_edge_driver#" .. ip, generateclientkey = true }, + body = key_response, + reusable = true, + }) + return scenario:activate(), api +end + +--- Assert that discovery fetched the bridge's config and asked it for an API key. +local function assert_bridge_queried(bridge, api) + bridge:assert_all_satisfied() + assert(http.count_requests(api, "POST", "/api") >= 1, "expected discovery to request an API key") +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, api = bridge_at(BRIDGE_IP, { + mac = BRIDGE_MAC, + swversion = "1968054000", + modelid = "BSB002", + name = BRIDGE_NAME, + }, { + { error = { type = 101, address = "/", description = "link button not pressed" } }, + }) + + start_discovery() + test.wait_for_events() + stop_discovery() + test.wait_for_events() + + assert_bridge_queried(bridge, api) + end +) + +test.register_coroutine_test( + "mDNS discovery creates a bridge device once an API key is obtained", + function() + local bridge, api = bridge_at(BRIDGE_IP, { + mac = BRIDGE_MAC, + swversion = "1968054000", + modelid = "BSB002", + name = BRIDGE_NAME, + }, { + { 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() + + assert_bridge_queried(bridge, api) + 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..ce347e2bd3 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua @@ -0,0 +1,233 @@ +--- The bridge's SSE event stream: connecting marks the bridge online, update/add/delete events +--- reach the right devices, and a dropped stream marks everything offline until it reconnects. +--- +--- The connectivity scan the driver makes once the stream connects is answered by a test-scoped +--- expectation registered after `activate()` in test_init, so a test can answer the scan after +--- a reconnect differently. + +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 + +-- The light's refresh: its device, zigbee connectivity and light state. `opts` is passed to each +-- expectation (e.g. `times` or `persistent`). +local function expect_light_refresh(opts) + local function expect(path, data) + local spec = { body = { errors = {}, data = data } } + for k, v in pairs(opts or {}) do spec[k] = v end + http.expect_request(rest, "GET", path, spec) + end + expect("/clip/v2/resource/device/" .. HUE_DEVICE_ID, + { { services = { { rtype = "zigbee_connectivity", rid = ZIGBEE_RID } } } }) + expect("/clip/v2/resource/zigbee_connectivity/" .. ZIGBEE_RID, + { { owner = { rid = HUE_DEVICE_ID }, status = "connected" } }) + expect("/clip/v2/resource/light/" .. LIGHT_RID, + { { id = LIGHT_RID, on = { on = true }, dimming = { brightness = 80 } } }) +end + +-- The refreshes injected by the light's added and init handlers. +expect_light_refresh({ times = 2 }) +-- SSE handshake (reusable for reconnections) +http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + +-- Custom test_init that pre-queues all init expectations before activating scenario +test.set_test_init_function(function() + scenario:activate() + + -- The connectivity scan after the stream connects reports only a device outside the fixture. + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + body = { + errors = {}, + data = { { owner = { rid = "unrelated-device-not-in-fixture" }, status = "connected" } }, + }, + }) + + 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_for_test(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") + + -- After reconnecting, the connectivity scan reports the light as connected, which refreshes it. + http.expect_request_for_test(rest_conn, "GET", "/clip/v2/resource/zigbee_connectivity", { + body = { + errors = {}, + data = { { owner = { rid = HUE_DEVICE_ID }, status = "connected" } } + }, + }) + expect_switch_and_level_emit() + expect_light_refresh({ persistent = false }) + + 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..1f1bd9fe4d --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_button_lifecycle.lua @@ -0,0 +1,38 @@ +--- A button whose state is in the discovery cache is added and initialized without any REST +--- requests (no address is registered, so any connection attempt would fail the test). + +local test = require "integration_test" +local hue_test_helpers = require "test.hue_test_helpers" +local Fields = require "fields" + +-- Use proper UUID format for Hue resource IDs +local BUTTON_RID = "aaaaaaaa-bbbb-cccc-dddd-111111111111" +local BUTTON_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-222222222222" +local POWER_RID = "aaaaaaaa-bbbb-cccc-dddd-333333333333" + +-- Single button device fixture WITHOUT SSE (lifecycle only) +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_button(BUTTON_RID, { + battery = 85, + device_id = BUTTON_DEVICE_ID, + power_rid = POWER_RID, + }) + :start() + +local mock_button = fixtures.devices[1] + +test.set_test_init_function(fixtures.test_init) + +test.register_coroutine_test( + "a cached button is added and initialized from its discovery state", + function() + test.wait_for_events() + local button = fixtures.driver_device(mock_button) + assert(button:get_field(Fields._ADDED) == true, "expected the button to have been added") + assert(button:get_field(Fields.DEVICE_TYPE) == "button", "expected the button's device type") + assert(button:get_field(Fields.HUE_DEVICE_ID) == BUTTON_DEVICE_ID, "expected the button's Hue device id") + 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..b630ae628f --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_button_sse.lua @@ -0,0 +1,103 @@ +--- Test for Hue button device with SSE events. +--- Uses HueDeviceBuilder and ConnectionScenario 2.0 with device-type-specific helper. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" + +-- Test constants +local BUTTON_RID = "aaaaaaaa-bbbb-cccc-dddd-111111111111" +local BUTTON_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-222222222222" +local POWER_RID = "aaaaaaaa-bbbb-cccc-dddd-333333333333" +local ZIGBEE_RID = "aaaaaaaa-bbbb-cccc-dddd-444444444444" + +-- Create test fixture using HueDeviceBuilder +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_button(BUTTON_RID, { + num_buttons = 1, + battery = 85, + label = "Hue Button", + device_id = BUTTON_DEVICE_ID, + power_rid = POWER_RID + }) + :enable_sse() + :start() + +local mock_bridge, mock_button = fixtures.bridge, fixtures.devices[1] + +-- Create connection scenario with REST and SSE connections +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- Setup all button init expectations using device-specific helper +-- Override zigbee_rid since the builder uses a default pattern +local button_config = fixtures.configs.button[1] +button_config.zigbee_rid = ZIGBEE_RID +hue_test_helpers.setup_button_init_expectations(rest, sse, button_config) + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +test.register_coroutine_test( + "the bridge's event stream connects and the button's init requests are made", + function() + hue_test_helpers.assert_event_stream_connected(scenario, mock_bridge) + 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..86250bbb54 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_child_device_lifecycle.lua @@ -0,0 +1,55 @@ +--- A child device whose resource state isn't in the discovery cache is treated as a "stray": +--- it isn't onboarded (no REST state fetch, no device fields set) until the bridge reports it. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local Fields = require "fields" + +-- A light that is in the discovery cache, alongside the device under test, which isn't. +local CACHED_LIGHT_RID = "33333333-3333-3333-3333-333333333333" +local NEW_LIGHT_RID = "44444444-4444-4444-4444-444444444444" + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(CACHED_LIGHT_RID, { on = { on = true } }, "white-and-color-ambiance.yml") + :start() + +local mock_bridge, mock_cached_light = fixtures.bridge, fixtures.devices[1] + +local mock_new_light = test.mock_device.build_test_lan_device({ + label = "New Hue Light", + profile = t_utils.get_profile_definition("white-and-color-ambiance.yml"), + parent_assigned_child_key = "light:" .. NEW_LIGHT_RID, + parent_device_id = mock_bridge.id, +}) + +local scenario, conns = hue_test_helpers.create_hue_scenario() + +hue_test_helpers.setup_scenario_test_init(function() + fixtures.test_init() + test.mock_device.add_test_device(mock_new_light) + -- Light init emits levelRange whether or not the light was onboarded. + hue_test_helpers.expect_light_init_events(mock_new_light) +end, scenario) + +test.register_coroutine_test( + "a child device with no cached resource state is left as a stray, not onboarded", + function() + test.wait_for_events() + + local stray = fixtures.driver_device(mock_new_light) + assert(stray:get_field(Fields._ADDED) == nil, "expected the stray light not to have been added") + assert(stray:get_field(Fields.HUE_DEVICE_ID) == nil and stray:get_field(Fields.DEVICE_TYPE) == nil, + "expected no Hue device fields on the stray light") + assert(http.count_requests(conns.rest, "GET", "/clip/v2/resource/light/" .. NEW_LIGHT_RID) == 0, + "expected no state request for the stray light") + + local cached = fixtures.driver_device(mock_cached_light) + assert(cached:get_field(Fields._ADDED) == true and cached:get_field(Fields.DEVICE_TYPE) == "light", + "expected the cached light to have been onboarded") + 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..8ede32d4be --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_contact_sensor_sse.lua @@ -0,0 +1,122 @@ +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 CONTACT_RID = "ffffffff-1111-1111-1111-111111111111" +local TAMPER_RID = "ffffffff-2222-2222-2222-222222222222" +local POWER_RID = "ffffffff-3333-3333-3333-333333333333" +local CONTACT_DEVICE_ID = "gggggggg-gggg-gggg-gggg-gggggggggggg" +local ZIGBEE_RID = "zigbee-rid-1" + +-- Contact sensor fixture WITH SSE enabled +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_contact(CONTACT_RID, { + battery = 90, + contact_state = "contact", -- "contact" = closed + tamper = "not_tampered", + device_id = CONTACT_DEVICE_ID, + power_rid = POWER_RID, + tamper_rid = TAMPER_RID, + }) + :enable_sse() + :start() + +local mock_bridge, mock_sensor = fixtures.bridge, fixtures.devices[1] + +-- Set up ConnectionScenario for this host:port +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- Setup all contact sensor init expectations using device-specific helper +local contact_config = fixtures.configs.contact[1] +contact_config.zigbee_rid = ZIGBEE_RID +hue_test_helpers.setup_contact_init_expectations(rest, sse, contact_config) + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +test.register_coroutine_test( + "the bridge's event stream connects and the contact sensor's init requests are made", + function() + hue_test_helpers.assert_event_stream_connected(scenario, mock_bridge) + 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..b41e7b1769 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_error_handling.lua @@ -0,0 +1,188 @@ +--- Error handling in Hue light commands and refresh: error statuses, Hue API errors, missing +--- services, unparseable responses, and a keep-alive connection the bridge has closed. +--- +--- A command that fails must not emit any capability events (the capability channel fails the +--- test on any it isn't expecting), and the driver must keep working afterwards. + +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 LIGHT_DEVICE_ID = "22222222-2222-2222-2222-222222222222" +local LIGHT_PATH = "/clip/v2/resource/light/" .. LIGHT_RID +local DEVICE_PATH = "/clip/v2/resource/device/" .. LIGHT_DEVICE_ID + +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] + +local scenario, conns = hue_test_helpers.create_hue_scenario() +local rest = conns.rest + +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +local function send_command(capability, command, args) + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = capability, component = "main", command = command, args = args or {} }, + }) + test.wait_for_events() +end + +local function queue_command(capability, command, args) + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = capability, component = "main", command = command, args = args or {} }, + }) +end + +local function hue_error(description, type) + return { errors = { { type = type, description = description } }, data = {} } +end + +--- After a failed command, the next one still reaches the bridge. +local function assert_next_command_sent() + http.expect_request_for_test(rest, "PUT", LIGHT_PATH, { + request_json = { on = { on = false } }, + body = { errors = {}, data = { { rid = LIGHT_RID, rtype = "light" } } }, + }) + send_command("switch", "off") + scenario:assert_all_satisfied({ test_scoped_only = true }) +end + +test.register_coroutine_test( + "a light command answered with 404 emits nothing and doesn't stop later commands", + function() + http.expect_request_for_test(rest, "PUT", LIGHT_PATH, { + request_json = { on = { on = true } }, + status = 404, + body = hue_error("Resource not found", "resource_not_found"), + }) + send_command("switch", "on") + assert_next_command_sent() + end +) + +test.register_coroutine_test( + "a light command answered with 500 emits nothing and doesn't stop later commands", + function() + http.expect_request_for_test(rest, "PUT", LIGHT_PATH, { + request_json = { dimming = { brightness = 50 } }, + status = 500, + body = hue_error("Internal server error", "internal_error"), + }) + send_command("switchLevel", "setLevel", { 50 }) + assert_next_command_sent() + end +) + +test.register_coroutine_test( + "a light command answered with a Hue API error emits nothing and doesn't stop later commands", + function() + http.expect_request_for_test(rest, "PUT", LIGHT_PATH, { + request_json = { on = { on = true } }, + body = hue_error("Light is unreachable"), + }) + send_command("switch", "on") + assert_next_command_sent() + end +) + +test.register_coroutine_test( + "a refresh of a device without a zigbee_connectivity service doesn't query connectivity", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + queue_command("refresh", "refresh") + http.await_request(rest, "GET", DEVICE_PATH):respond({ + body = { + errors = {}, + data = { { id = LIGHT_DEVICE_ID, services = { { rtype = "light", rid = LIGHT_RID } } } }, + }, + }) + test.wait_for_events() + assert(http.count_requests(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { prefix = true }) == 0, + "expected no connectivity request without a zigbee_connectivity service") + end +) + +test.register_coroutine_test( + "a refresh of a device the bridge no longer has (404) doesn't query connectivity", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + queue_command("refresh", "refresh") + http.await_request(rest, "GET", DEVICE_PATH):respond({ + status = 404, + body = hue_error("Device not found", "resource_not_found"), + }) + test.wait_for_events() + assert(http.count_requests(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { prefix = true }) == 0, + "expected no connectivity request for a device the bridge doesn't have") + end +) + +test.register_coroutine_test( + "a command answered with malformed JSON emits nothing and doesn't stop later commands", + function() + rest:expect_for_test({ + request = { pattern = "^PUT " .. hue_test_helpers.escape_uuid(LIGHT_PATH) }, + responses = { + { data = http.format_response(200, { ["content-type"] = "application/json" }, "{ invalid json") } + }, + }) + send_command("switch", "on") + assert_next_command_sent() + end +) + +test.register_coroutine_test( + "a command answered with an empty body emits nothing and doesn't stop later commands", + function() + rest:expect_for_test({ + request = { pattern = "^PUT " .. hue_test_helpers.escape_uuid(LIGHT_PATH) }, + responses = { + { data = http.format_response(200, { ["content-type"] = "application/json" }, "") } + }, + }) + send_command("switchLevel", "setLevel", { 25 }) + assert_next_command_sent() + 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 OK = { body = { data = { { rid = LIGHT_RID, rtype = "light" } }, errors = {} } } + + queue_command("switch", "on") + local first = http.await_request(rest, "PUT", LIGHT_PATH) + first:respond(OK) + test.wait_for_events() + + -- The bridge closes the idle keep-alive connection. The next command goes out on it, then is + -- resent on a new connection; the resend can follow before the test's next step, so expect + -- both requests up front. + rest:close_connection() + local puts = http.expect_request(rest, "PUT", LIGHT_PATH, { defer = true, times = 2 }) + queue_command("switch", "on") + local stale = puts:await() + assert(stale.connection_index == first.connection_index, + "expected the command to go out first on the connection the bridge had closed") + local resent = puts:await() + assert(resent.connection_index == first.connection_index + 1, + "expected the command to be resent on a new connection") + resent:respond(OK) + test.wait_for_events() + 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..ae8f7ae377 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_commands.lua @@ -0,0 +1,234 @@ +--- Test for Hue light command handling (switch, level, color, temperature). +--- Uses ConnectionScenario 2.0 for PUT command testing. + +local test = require "integration_test" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local Fields = require "fields" + +local LIGHT_RID = "11111111-1111-1111-1111-111111111111" + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LIGHT_RID, { + on = { on = true }, + dimming = { brightness = 100 }, + color = { xy = { x = 0.3, y = 0.3 }, gamut = { red = { x = 0.7, y = 0.3 }, green = { x = 0.2, y = 0.7 }, blue = { x = 0.15, y = 0.05 } } }, + color_temperature = { mirek = 366, mirek_schema = { mirek_minimum = 153, mirek_maximum = 500 } }, + mode = "normal", + }, "white-and-color-ambiance.yml") + :start() + +local mock_light = fixtures.devices[1] + +-- Set up ConnectionScenario for PUT command testing +local scenario, conns = hue_test_helpers.create_hue_scenario() +local rest = conns.rest + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +local LIGHT_PATH = "/clip/v2/resource/light/" .. LIGHT_RID + +--- The xy the driver sent in its one PUT for the light. +local function sent_xy() + local requests = http.find_requests(rest, "PUT", LIGHT_PATH) + assert(#requests == 1, string.format("expected one PUT for the light, got %d", #requests)) + local xy = requests[1].json and requests[1].json.color and requests[1].json.color.xy + assert(xy and type(xy.x) == "number" and type(xy.y) == "number", "expected color.xy in the request body") + return xy +end + +local function assert_xy_near(xy, x, y, what) + local tolerance = 0.01 + assert(math.abs(xy.x - x) < tolerance and math.abs(xy.y - y) < tolerance, + string.format("expected %s near (%.4f, %.4f), got (%.4f, %.4f)", what, x, y, xy.x, xy.y)) +end + + +-- 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 + http.assert_sent_json(rest, "PUT", LIGHT_PATH, { on = { on = true } }) + 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 + http.assert_sent_json(rest, "PUT", LIGHT_PATH, { on = { on = false } }) + 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 + http.assert_sent_json(rest, "PUT", LIGHT_PATH, { dimming = { brightness = 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) + http.assert_sent_json(rest, "PUT", LIGHT_PATH, { color_temperature = { mirek = 333 }, on = { on = true } }) + 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() + + -- Hue 0 at full saturation is pure red: the red corner of the light's gamut. + assert_xy_near(sent_xy(), 0.7, 0.3, "fully saturated red") + http.assert_sent_json(rest, "PUT", LIGHT_PATH, { on = { on = true } }) + end +) + +test.register_coroutine_test( + "setHue combines the new hue with the light's current saturation", + function() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } } } + }) + fixtures.driver_device(mock_light):set_field(Fields.COLOR_SATURATION, 100) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "colorControl", component = "main", command = "setHue", args = { 0 } }, + }) + test.wait_for_events() + + -- Hue 0 at full saturation is pure red: the red corner of the light's gamut. + assert_xy_near(sent_xy(), 0.7, 0.3, "fully saturated red") + end +) + +test.register_coroutine_test( + "setSaturation combines the new saturation with the light's current hue", + function() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } } } + }) + fixtures.driver_device(mock_light):set_field(Fields.COLOR_HUE, 33) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "colorControl", component = "main", command = "setSaturation", args = { 0 } }, + }) + test.wait_for_events() + + -- No saturation is white whatever the hue: Hue's D65 white point. + assert_xy_near(sent_xy(), 0.3227, 0.3290, "white") + end +) + +-- Out-of-range values sent from the SmartThings API during physical testing (scenario 15), and the +-- request bodies the driver sent the bridge for them. +local function expect_light_put() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { data = { { rid = LIGHT_RID, rtype = "light" } } } + }) +end + +test.register_coroutine_test( + "setLevel 0 is clamped to the minimum brightness of 1", + function() + expect_light_put() + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "switchLevel", component = "main", command = "setLevel", args = { 0 } }, + }) + test.wait_for_events() + + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { dimming = { brightness = 1 } }) + end +) + +for _, case in ipairs({ + { kelvin = 100, mirek = 500 }, -- below the light's range: its warmest setting + { kelvin = 10000, mirek = 153 }, -- above the light's range: its coolest setting +}) do + test.register_coroutine_test( + string.format("setColorTemperature %dK is clamped to the light's range (mirek %d)", case.kelvin, case.mirek), + function() + expect_light_put() + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "colorTemperature", component = "main", command = "setColorTemperature", args = { case.kelvin } }, + }) + test.wait_for_events() + + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, + { color_temperature = { mirek = case.mirek }, on = { on = true } }) + end + ) +end + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_light_refresh.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_refresh.lua new file mode 100644 index 0000000000..3ec779bd2e --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_refresh.lua @@ -0,0 +1,103 @@ +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" + +local LIGHT_RID = "22222222-2222-2222-2222-222222222222" +local HUE_DEVICE_ID = "device-uuid-1" +local ZIGBEE_RID = "zigbee-conn-1" + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LIGHT_RID, { + on = { on = true }, + dimming = { brightness = 80 }, + hue_device_id = HUE_DEVICE_ID, + }, "white-and-color-ambiance.yml") + :start() + +local mock_bridge, mock_light = fixtures.bridge, fixtures.devices[1] + +-- Set up ConnectionScenario for REST-only testing +local scenario, conns = hue_test_helpers.create_hue_scenario() +local rest = conns.rest + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +-- Define refresh sequence expectations +-- During refresh, the driver queries device info (to get zigbee_connectivity RID), +-- then checks zigbee connectivity, then queries light state + +-- 1. GET device info to find zigbee_connectivity resource (reusable across tests) +hue_test_helpers.expect_device_info(rest, HUE_DEVICE_ID, + {{ rtype = "zigbee_connectivity", rid = ZIGBEE_RID }}, + { reusable = true } +) + +-- 2. GET zigbee connectivity status (reusable across tests) +hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID, + { owner = HUE_DEVICE_ID, reusable = true } +) + +-- Note: Light state expectations are test-specific and defined in each test body + +test.register_coroutine_test( + "refresh command reads light state over REST and emits switch/switchLevel events", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + + -- Test-specific expectation: light is on and bright + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { + errors = {}, + data = {{ id = LIGHT_RID, on = { on = true }, dimming = { brightness = 80 } }} + } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switch.switch.on()) + ) + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switchLevel.level(80)) + ) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "refresh command reflects an off/dimmed state from the REST response", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + + -- Test-specific expectation: light is off and dim + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = { + errors = {}, + data = {{ id = LIGHT_RID, on = { on = false }, dimming = { brightness = 15 } }} + } + }) + + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switch.switch.off()) + ) + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switchLevel.level(15)) + ) + test.wait_for_events() + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_motion_sensor_sse.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_motion_sensor_sse.lua new file mode 100644 index 0000000000..21f7f69a66 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_motion_sensor_sse.lua @@ -0,0 +1,131 @@ +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" + +-- Use proper UUID format for Hue resource IDs +local MOTION_RID = "dddddddd-1111-1111-1111-111111111111" +local TEMP_RID = "dddddddd-2222-2222-2222-222222222222" +local LIGHT_RID = "dddddddd-3333-3333-3333-333333333333" +local POWER_RID = "dddddddd-4444-4444-4444-444444444444" +local MOTION_DEVICE_ID = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" +local ZIGBEE_RID = "zigbee-rid-1" + +-- Motion sensor fixture WITH SSE enabled +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_motion(MOTION_RID, { + battery = 95, + motion = false, + temperature = 20.0, + light_level = 30000, -- ~1000 lux + device_id = MOTION_DEVICE_ID, + power_rid = POWER_RID, + temperature_rid = TEMP_RID, + light_level_rid = LIGHT_RID, + }) + :enable_sse() + :start() + +local mock_bridge, mock_sensor = fixtures.bridge, fixtures.devices[1] + +-- Set up ConnectionScenario for this host:port +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- Setup all motion sensor init expectations using device-specific helper +local motion_config = fixtures.configs.motion[1] +motion_config.zigbee_rid = ZIGBEE_RID +hue_test_helpers.setup_motion_init_expectations(rest, sse, motion_config) + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +test.register_coroutine_test( + "the bridge's event stream connects and the motion sensor's init requests are made", + function() + hue_test_helpers.assert_event_stream_connected(scenario, mock_bridge) + 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..ce015948da --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_multibutton_sse.lua @@ -0,0 +1,95 @@ +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" + +-- Use proper UUID format for Hue resource IDs (4-button remote) +local BUTTON_RID_1 = "aaaaaaaa-1111-1111-1111-111111111111" +local BUTTON_RID_2 = "aaaaaaaa-2222-2222-2222-222222222222" +local BUTTON_RID_3 = "aaaaaaaa-3333-3333-3333-333333333333" +local BUTTON_RID_4 = "aaaaaaaa-4444-4444-4444-444444444444" +local BUTTON_DEVICE_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +local POWER_RID = "cccccccc-cccc-cccc-cccc-cccccccccccc" +local ZIGBEE_RID = "zigbee-rid-1" + +-- 4-button remote fixture WITH SSE enabled +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_button(BUTTON_RID_1, { + num_buttons = 4, + battery = 90, + device_id = BUTTON_DEVICE_ID, + power_rid = POWER_RID, + button_rids = { BUTTON_RID_1, BUTTON_RID_2, BUTTON_RID_3, BUTTON_RID_4 }, + label = "Hue Dimmer Remote", + }, "4-button-remote.yml") + :enable_sse() + :start() + +local mock_remote = fixtures.devices[1] + +-- Set up ConnectionScenario for this host:port +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- Setup all button init expectations using device-specific helper +local button_config = fixtures.configs.button[1] +button_config.zigbee_rid = ZIGBEE_RID +hue_test_helpers.setup_button_init_expectations(rest, sse, button_config) + +-- Setup test init with scenario activation +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +test.register_coroutine_test( + "SSE event to button 1 (main component) routes correctly", + function() + test.socket.capability:__expect_send( + mock_remote:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) + ) + + http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_1, "short_release") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE event to button 2 routes to button2 component", + function() + test.socket.capability:__expect_send( + mock_remote:generate_test_message("button2", capabilities.button.button.held({ state_change = true })) + ) + + http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_2, "long_press") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE event to button 3 routes to button3 component", + function() + test.socket.capability:__expect_send( + mock_remote:generate_test_message("button3", capabilities.button.button.pushed({ state_change = true })) + ) + + http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_3, "short_release") }) + + test.wait_for_events() + end +) + +test.register_coroutine_test( + "SSE event to button 4 routes to button4 component", + function() + test.socket.capability:__expect_send( + mock_remote:generate_test_message("button4", capabilities.button.button.held({ state_change = true })) + ) + + http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_4, "long_press") }) + + test.wait_for_events() + end +) + +test.run_registered_tests() From 1784d2b64808e6ee4c3617083afbc90fd209760e Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 08:31:23 -0500 Subject: [PATCH 02/22] philips-hue: Add tests replaying payloads recorded on real hardware test/fixtures/capture_payloads.lua holds CLIP v2 SSE events and REST responses recorded by an instrumented driver during physical testing, with every resource UUID, MAC, IP, bridge id, Zigbee PAN id and v1 scene id replaced by a stable fake. Payloads are kept as their original JSON text. Tests built from them: - a recorded LCT014 add event creates exactly one child device, a recorded smart plug removal deletes it, and 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; - an on/off-only smart plug built from its recorded light and device resources initializes, reports its state and takes commands; - 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/fixtures/capture_payloads.lua | 83 ++++++++++++ .../src/test/test_hue_bridge_discovery.lua | 45 +++++++ .../test/test_hue_bridge_resource_events.lua | 124 ++++++++++++++++++ .../src/test/test_hue_driver_restart.lua | 117 +++++++++++++++++ .../philips-hue/src/test/test_hue_plug.lua | 101 ++++++++++++++ 5 files changed, 470 insertions(+) create mode 100644 drivers/SmartThings/philips-hue/src/test/fixtures/capture_payloads.lua 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 create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_plug.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..32169b3c96 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/fixtures/capture_payloads.lua @@ -0,0 +1,83 @@ +--- Real Hue bridge payloads recorded by the instrumented driver during physical testing. +--- Identifiers (resource UUIDs, bridge ids, MACs, IPs, the Zigbee PAN id and v1 scene ids) are +--- replaced with stable fakes; everything else (names, timestamps, versions, state) is as +--- recorded. 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/0000000000000001","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/test_hue_bridge_discovery.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua index ec7740cdb6..7518ba8193 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 @@ -130,4 +130,49 @@ 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, paired_api = bridge_at(PAIRED_IP, capture_payloads.get("bridge_config"), { + { success = { username = "new-bridge-api-key", clientkey = "some-client-key" } }, + }) + local unpaired, unpaired_api = bridge_at(UNPAIRED_IP, capture_payloads.get("unpaired_bridge_config"), { + { 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() + + assert_bridge_queried(paired, paired_api) + assert_bridge_queried(unpaired, unpaired_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..1ed3f51f67 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_resource_events.lua @@ -0,0 +1,124 @@ +--- Tests for bridge resource add/delete events over SSE, replaying event payloads recorded on real +--- hardware (see test/fixtures/capture_payloads.lua): a colour lamp being added to the bridge, the +--- smart plug being removed from it, and resource events the driver has no use for. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local mock_devices_api = require "integration_test.mock_devices_api" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local capture_payloads = require "test.fixtures.capture_payloads" + +local ids = capture_payloads.ids + +local function plug_light_resource() + local resp = capture_payloads.get("plug_light") + return resp +end + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(ids.plug_light, { on = { on = true }, hue_device_id = ids.plug_device, label = "Hue smart plug 1" }, + "plug.yml") + :enable_sse() + :start() + +local mock_bridge, mock_plug = fixtures.bridge, fixtures.devices[1] + +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +http.expect_request(rest, "GET", "/clip/v2/resource/device/" .. ids.plug_device, { + status = 200, body = capture_payloads.get("plug_device"), reusable = true, +}) +hue_test_helpers.expect_zigbee_connectivity(rest, ids.plug_zigbee, { owner = ids.plug_device, reusable = true }) +http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. ids.plug_light, { + status = 200, body = plug_light_resource(), reusable = true, +}) + +local function expect_plug_refresh_events() + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities["samsungim.hueSyncMode"].mode("normal"))) + test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities.switch.switch.on())) +end + +-- Answered once: these tests never reconnect the event stream. +http.expect_sse_handshake(sse, "/eventstream/clip/v2", false) + +test.set_test_init_function(function() + scenario:activate() + -- The connectivity scan after the stream connects reports only a device outside the fixture. + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + body = { errors = {}, data = { { owner = { rid = "unrelated-device-not-in-fixture" }, status = "connected" } } }, + reusable = true, + }) + + expect_plug_refresh_events() -- .added's injected refresh + fixtures.test_init() + expect_plug_refresh_events() -- .init's injected refresh +end) + +local function sse_conn() + test.wait_for_events() + return scenario:get_connection("sse") +end + +test.register_coroutine_test( + "a recorded colour lamp add creates exactly one child device", + function() + local conn = sse_conn() + + -- The bridge sends the device together with its zigbee_connectivity, light, entertainment, + -- motion_area_candidate and device_software_update resources; only the device add matters. + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. ids.color_lamp_light, { + status = 200, body = capture_payloads.get("color_lamp_light"), + }) + mock_devices_api.__expect_create_device({ + type = "EDGE_CHILD", + label = "Hue color lamp 1", + profileReference = "white-and-color-ambiance", + parentDeviceId = mock_bridge.id, + manufacturer = "Signify Netherlands B.V.", + model = "LCT014", + parentAssignedChildKey = "light:" .. ids.color_lamp_light, + }) + + http.queue_sse_event(conn, capture_payloads.get("color_lamp_added")) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "resource events for things the driver doesn't model are ignored", + function() + local conn = sse_conn() + + for _, name in ipairs({ + "noise_homekit", + "noise_discovery_geolocation", + "noise_behavior_script", + "noise_behavior_instance", + }) do + http.queue_sse_event(conn, capture_payloads.get(name)) + end + test.wait_for_events() + + assert(mock_devices_api.__is_device_online(mock_bridge.id) == true, "expected the bridge to stay online") + assert(mock_devices_api.__is_device_online(mock_plug.id) == true, "expected the plug to stay online") + assert(not mock_devices_api.__is_device_deleted(mock_plug.id), "expected the plug not to be deleted") + end +) + +test.register_coroutine_test( + "a recorded smart plug removal deletes the plug", + function() + local conn = sse_conn() + + -- Recorded alongside the delete: the plug's room and bridge_home dropping it as a child. + http.queue_sse_event(conn, capture_payloads.get("plug_deleted")) + test.wait_until(function() return mock_devices_api.__is_device_deleted(mock_plug.id) end, + { message = "the plug being deleted after the bridge reported its light resource deleted" }) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua new file mode 100644 index 0000000000..1f4d6cffca --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_driver_restart.lua @@ -0,0 +1,117 @@ +--- Tests for a driver restart with already-onboarded devices (physical test scenario 2): each device +--- only receives `init`, with the fields an earlier `added` persisted, and is brought up to date by +--- the refresh that follows the event stream's connectivity check rather than by re-onboarding. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local mock_devices_api = require "integration_test.mock_devices_api" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local capture_payloads = require "test.fixtures.capture_payloads" +local Fields = require "fields" +local HueDeviceTypes = require "hue_device_types" + +local ids = capture_payloads.ids + +local function lamp_light_resource() + local resp = capture_payloads.get("color_lamp_light") + return resp +end + +local lamp_repr = lamp_light_resource().data[1] + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(ids.color_lamp_light, { + on = lamp_repr.on, + dimming = lamp_repr.dimming, + color = lamp_repr.color, + color_temperature = lamp_repr.color_temperature, + hue_device_id = ids.color_lamp_device, + label = lamp_repr.metadata.name, + }, "white-and-color-ambiance.yml") + :enable_sse() + :start() + +local mock_bridge, mock_lamp = fixtures.bridge, fixtures.devices[1] + +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +http.expect_request(rest, "GET", "/clip/v2/resource/device/" .. ids.color_lamp_device, { + status = 200, body = capture_payloads.get("color_lamp_device"), reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity/" .. ids.color_lamp_zigbee, { + status = 200, body = capture_payloads.get("color_lamp_zigbee"), reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. ids.color_lamp_light, { + status = 200, body = lamp_light_resource(), reusable = true, +}) + +-- The fields the earlier `added` (and first `init`) left persisted. +local function seed_persisted_fields() + mock_bridge:set_field(Fields._ADDED, true, { persist = true }) + mock_lamp:set_field(Fields.DEVICE_TYPE, HueDeviceTypes.LIGHT, { persist = true }) + mock_lamp:set_field(Fields.RESOURCE_ID, ids.color_lamp_light, { persist = true }) + mock_lamp:set_field(Fields.HUE_DEVICE_ID, ids.color_lamp_device, { persist = true }) + mock_lamp:set_field(Fields.PARENT_DEVICE_ID, mock_bridge.id, { persist = true }) + mock_lamp:set_field(Fields.GAMUT, lamp_repr.color.gamut, { persist = true }) + mock_lamp:set_field(Fields.MIN_KELVIN, 2000, { persist = true }) + mock_lamp:set_field(Fields.MAX_KELVIN, 6500, { persist = true }) + mock_lamp:set_field(Fields._ADDED, true, { persist = true }) + mock_lamp:set_field(Fields._REFRESH_AFTER_INIT, false, { persist = true }) +end + +http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + +test.set_test_init_function(function() + scenario:activate() + -- The connectivity check after the event stream opens reports the lamp connected. + http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + status = 200, + body = { errors = {}, data = { { + id = ids.color_lamp_zigbee, + owner = { rid = ids.color_lamp_device, rtype = "device" }, + status = "connected", + type = "zigbee_connectivity", + } } }, + reusable = false, + persistent = false, + }) + + fixtures.test_init() -- adds the devices; registers the lamp's init levelRange emit + seed_persisted_fields() + + -- The refresh injected once the connectivity check reports the lamp connected. + test.socket.capability:__set_channel_ordering("relaxed") + for _, event in ipairs({ + capabilities.colorTemperature.colorTemperatureRange({ minimum = 2000, maximum = 6500 }), + capabilities["samsungim.hueSyncMode"].mode("normal"), + capabilities.switch.switch.on(), + capabilities.switchLevel.level(100), + capabilities.colorTemperature.colorTemperature(2732), + capabilities.colorControl.hue(11), + capabilities.colorControl.saturation(53), + }) do + test.socket.capability:__expect_send(mock_lamp:generate_test_message("main", event)) + end +end) + +test.register_coroutine_test( + "after a restart an onboarded light is initialized and refreshed once, without being re-added", + function() + test.wait_until(function() return mock_devices_api.__is_device_online(mock_lamp.id) end, + { message = "the lamp coming online" }) + test.wait_for_events() + + assert(mock_devices_api.__is_device_online(mock_bridge.id) == true, "expected the bridge to be online") + assert(mock_devices_api.__is_device_online(mock_lamp.id) == true, "expected the lamp to be online") + + -- One refresh: the device, connectivity and light GETs happen once each. + local sent = rest:get_sent_log() + local _, light_gets = sent:gsub("GET /clip/v2/resource/light/" .. hue_test_helpers.escape_uuid(ids.color_lamp_light), "") + assert(light_gets == 1, string.format("expected 1 light refresh, got %d", light_gets)) + end +) + +test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua new file mode 100644 index 0000000000..5e562717c9 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua @@ -0,0 +1,101 @@ +--- Tests for an on/off Hue smart plug (LOM010), built from payloads recorded on real hardware +--- (see test/fixtures/capture_payloads.lua). The plug is a `light` resource with no `dimming`. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local capture_payloads = require "test.fixtures.capture_payloads" + +local PLUG_RID = capture_payloads.ids.plug_light +local PLUG_DEVICE_ID = capture_payloads.ids.plug_device +local PLUG_ZIGBEE_RID = capture_payloads.ids.plug_zigbee + +local function plug_light_resource(on) + local resp = capture_payloads.get("plug_light") + if on ~= nil then resp.data[1].on.on = on end + return resp +end + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(PLUG_RID, { + on = { on = true }, + hue_device_id = PLUG_DEVICE_ID, + label = "Hue smart plug 1", + }, "plug.yml") + :start() + +local mock_bridge, mock_plug = fixtures.bridge, fixtures.devices[1] + +local scenario, conns = hue_test_helpers.create_hue_scenario() +local rest = conns.rest + +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) + +http.expect_request(rest, "GET", "/clip/v2/resource/device/" .. PLUG_DEVICE_ID, { + status = 200, + body = capture_payloads.get("plug_device"), +}) +hue_test_helpers.expect_zigbee_connectivity(rest, PLUG_ZIGBEE_RID, { owner = PLUG_DEVICE_ID, reusable = true }) + +local function refresh() + test.socket.capability:__queue_receive({ + mock_plug.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + test.socket.capability:__set_channel_ordering("relaxed") + test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities["samsungim.hueSyncMode"].mode("normal"))) +end + +test.register_coroutine_test( + "refreshing a plug emits its switch and sync mode state", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. PLUG_RID, { + status = 200, + body = plug_light_resource(), + }) + + refresh() + test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities.switch.switch.on())) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "refreshing a plug that is off emits switch off", + function() + hue_test_helpers.mark_bridge_initialized(mock_bridge) + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. PLUG_RID, { + status = 200, + body = plug_light_resource(false), + }) + + refresh() + test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities.switch.switch.off())) + test.wait_for_events() + end +) + +for _, command in ipairs({ "on", "off" }) do + test.register_coroutine_test( + "switch " .. command .. " sends a PUT to the plug's light resource", + function() + http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. PLUG_RID, { + status = 200, + body = { data = { { rid = PLUG_RID, rtype = "light" } }, errors = {} }, + }) + + test.socket.capability:__queue_receive({ + mock_plug.id, + { capability = "switch", component = "main", command = command, args = {} }, + }) + test.wait_for_events() + + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. PLUG_RID, { on = { on = command == "on" } }) + end + ) +end + +test.run_registered_tests() From 9b9a2d0578043f2cead9a7b192de92236e2196fe Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 08:32:08 -0500 Subject: [PATCH 03/22] philips-hue: Back off between failed bridge connectivity and group scan retries Once the event stream connects, the driver scans the bridge's Zigbee connectivity and then its rooms and zones, retrying until each succeeds. Both loops retried immediately, so a bridge answering with an error (or not answering) got the same requests back to back for as long as it kept failing. Wait with a capped backoff between attempts. Tests answer each scan with errors twice and check the attempts are spread over time; without the backoff all three are made at once. Co-Authored-By: Claude Opus 5.5 --- .../test/test_hue_bridge_connect_scans.lua | 88 +++++++++++++++++++ .../philips-hue/src/utils/grouped_utils.lua | 5 +- .../src/utils/hue_bridge_utils.lua | 19 ++-- 3 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua new file mode 100644 index 0000000000..5786ab67e5 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua @@ -0,0 +1,88 @@ +--- The scans the driver makes once the bridge's event stream connects: the Zigbee connectivity of +--- its devices, followed by a (debounced) scan of its rooms and zones. + +local test = require "integration_test" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" + +local CONNECTIVITY_PATH = "/clip/v2/resource/zigbee_connectivity" +local ROOM_PATH = "/clip/v2/resource/room" + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :enable_sse() + :start() + +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + +local function base_test_init() + fixtures.test_init() + scenario:activate() +end +test.set_test_init_function(base_test_init) + +local CONNECTED = { errors = {}, data = { { owner = { rid = "some-device" }, status = "connected" } } } +local EMPTY = { errors = {}, data = {} } +local FAILED = { errors = { { description = "internal error" } } } + +-- The scans start as soon as the driver does, before the test body runs, so each test registers +-- the scan requests it answers itself in test_init, as deferred expectations; the body then answers +-- each attempt in turn. An attempt beyond the ones a test expects is an unexpected request, which +-- fails the test. +local scans + +--- Wait for the driver's next scan request, answer it, and return the mock time it was made at. +local function answer_next(response) + local req = scans:await({ max_turns = 500 }) + local made_at = os.time() + req:respond(response) + return made_at +end + +--- test_init for a test that answers `times` connectivity scan requests itself. +local function answer_connectivity_scans(times) + return function() + base_test_init() + scans = http.expect_request(rest, "GET", CONNECTIVITY_PATH, { defer = true, times = times }) + end +end + +--- test_init for a test that answers `times` room scan requests itself, once the connectivity +--- scan succeeds. (Zone requests are answered by the scenario's defaults.) +local function answer_room_scans(times) + return function() + base_test_init() + http.expect_request(rest, "GET", CONNECTIVITY_PATH, { body = CONNECTED }) + scans = http.expect_request(rest, "GET", ROOM_PATH, { defer = true, times = times }) + end +end + +test.register_coroutine_test( + "a failed connectivity scan is retried after a backoff, not immediately", + function() + local first = answer_next({ status = 500, body = FAILED }) + answer_next({ status = 500, body = FAILED }) + local third = answer_next({ body = CONNECTED }) + -- The driver's backoff starts at 0s (plus up to 0.5s of jitter) and grows from there. + assert(third > first, string.format( + "expected the driver to back off between attempts, but all three were made at t=%s", first)) + end, + { test_init = answer_connectivity_scans(3) } +) + +test.register_coroutine_test( + "a failed room scan is retried after a backoff, not immediately", + function() + local first = answer_next({ status = 500, body = FAILED }) + answer_next({ status = 500, body = FAILED }) + local third = answer_next({ body = EMPTY }) + assert(third > first, string.format( + "expected the driver to back off between attempts, but all three were made at t=%s", first)) + end, + { test_init = answer_room_scans(3) } +) + +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..8b6e09ee32 100644 --- a/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua @@ -138,13 +138,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 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..0086d16431 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 dd4f4819e568df4cd7da49b7119fcb88639a3a32 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 08:32:29 -0500 Subject: [PATCH 04/22] philips-hue: Finish the connectivity scan when the bridge reports no Zigbee devices The connectivity scan after the event stream connects only counted as done once the bridge returned at least one zigbee_connectivity record. A bridge with no Zigbee devices answers with an empty list, so the scan was repeated forever (every backoff interval, since the previous fix; back to back before it) and the room and zone scan queued after it never ran. Treat any successful response as a complete scan. Co-Authored-By: Claude Opus 5.5 --- .../src/test/test_hue_bridge_connect_scans.lua | 14 ++++++++++++++ .../philips-hue/src/utils/hue_bridge_utils.lua | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua index 5786ab67e5..6fd6f5d030 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua @@ -85,4 +85,18 @@ test.register_coroutine_test( { test_init = answer_room_scans(3) } ) +test.register_coroutine_test( + "a connectivity scan reporting no devices is complete: it isn't repeated, and the room scan follows", + function() + -- The connectivity scan is answered once (a second one would be an unexpected request). + http.await_request(rest, "GET", ROOM_PATH, { max_turns = 500 }):respond({ body = EMPTY }) + end, + { + test_init = function() + base_test_init() + http.expect_request(rest, "GET", CONNECTIVITY_PATH, { body = EMPTY }) + 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 0086d16431..0b49acb770 100644 --- a/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua @@ -88,7 +88,8 @@ 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 - elseif connectivity_status.data and #connectivity_status.data > 0 then + elseif connectivity_status.data then + -- A bridge with no Zigbee devices answers with no data; that is still a complete scan. scanned = true for _, status in ipairs(connectivity_status.data) do local hue_device_id = (status.owner and status.owner.rid) or "" From 2f6596d96810942f7cc7e9f489d6d7afa917c7eb Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 08:32:45 -0500 Subject: [PATCH 05/22] 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 test replays the recorded colour-mode update for a light with a non-default gamut, and fails on either a colour temperature event or a later setColor clamped to the default gamut. Co-Authored-By: Claude Opus 5.5 --- .../src/handlers/attribute_emitters.lua | 54 +++++--- .../src/test/test_hue_light_color.lua | 126 ++++++++++++++++++ 2 files changed, 159 insertions(+), 21 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/test/test_hue_light_color.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua new file mode 100644 index 0000000000..2fb64fd2de --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua @@ -0,0 +1,126 @@ +--- Tests for colour state reported by the bridge, built from payloads recorded on real hardware +--- (see test/fixtures/capture_payloads.lua). + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local capture_payloads = require "test.fixtures.capture_payloads" + +local LIGHT_RID = capture_payloads.ids.color_lamp_2_light +local ZIGBEE_RID = "zigbee-color-lamp-2" + +-- Hue gamut A (e.g. LCT001/LLC010). The recorded lamp is gamut C, which is also the driver's +-- default gamut, so the recorded light resource is given gamut A here to make a fallback to the +-- default gamut observable. +local GAMUT_A = { + red = { x = 0.704, y = 0.296 }, + green = { x = 0.2151, y = 0.7106 }, + blue = { x = 0.138, y = 0.08 }, +} + +local function light_resource() + local resp = capture_payloads.get("color_lamp_2_light") + resp.data[1].color.gamut = GAMUT_A + resp.data[1].color.gamut_type = "A" + return resp +end + +local light_repr = light_resource().data[1] +local HUE_DEVICE_ID = light_repr.owner.rid + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LIGHT_RID, { + on = light_repr.on, + dimming = light_repr.dimming, + color = light_repr.color, + color_temperature = light_repr.color_temperature, + hue_device_id = HUE_DEVICE_ID, + label = light_repr.metadata.name, + }, "white-and-color-ambiance.yml") + :enable_sse() + :start() + +local mock_light = fixtures.devices[1] + +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- The events emitted for the recorded light resource on a refresh. The colour temperature range +-- is only emitted when it changes, i.e. on the first refresh. +local function expect_light_state_events(first_refresh) + if first_refresh then + test.socket.capability:__expect_send(mock_light:generate_test_message("main", + capabilities.colorTemperature.colorTemperatureRange({ minimum = 2000, maximum = 6500 }))) + end + for _, event in ipairs({ + capabilities["samsungim.hueSyncMode"].mode("normal"), + capabilities.switch.switch.on(), + capabilities.switchLevel.level(100), + capabilities.colorTemperature.colorTemperature(2710), + capabilities.colorControl.hue(11), + capabilities.colorControl.saturation(54), + }) do + test.socket.capability:__expect_send(mock_light:generate_test_message("main", event)) + end +end + +local function queue_light_refresh() + hue_test_helpers.expect_device_info(rest, HUE_DEVICE_ID, + { { rtype = "zigbee_connectivity", rid = ZIGBEE_RID } }, { reusable = false }) + hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID, { owner = HUE_DEVICE_ID, reusable = false }) + http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. LIGHT_RID, { + status = 200, + body = light_resource(), + }) +end + +queue_light_refresh() -- .added's injected refresh +queue_light_refresh() -- .init's injected refresh +http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + +test.set_test_init_function(function() + 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 emits hue and saturation without a colour temperature, and keeps the light's gamut", + function() + test.wait_for_events() + + -- Recorded after a setColor to red: the update carries only xy (no gamut), and the bridge + -- reports `mirek: null, mirek_valid: false`. Any colour temperature event would fail the test. + 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 = {} }, + }) + -- A later setColor still uses the light's own gamut. Pure green: clamped to gamut A's green corner, not the default (gamut C) corner at x = 0.17. + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "colorControl", component = "main", command = "setColor", + args = { { hue = 100 / 3, saturation = 100 } } }, + }) + test.wait_for_events() + http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, + { color = { xy = { x = 0.2151, y = 0.7106 } } }) + end +) + +test.run_registered_tests() From 0ce64614e3da356b924bbe1040d48cd294b433da Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 08:33:13 -0500 Subject: [PATCH 06/22] philips-hue: Keep a light's colour temperature range on partial updates The colour temperature range is computed from a light's mirek_schema, which the full light resource carries but SSE updates don't. An update without one fell back to the default schema, so for any light whose range isn't the default (e.g. a white ambiance lamp reaching 2200K) every colour temperature change over SSE reset its range to 2000K-6500K, and clamped the reported temperature to that, until the next refresh. Only recompute the range when the update has a schema, or no range is known yet. Co-Authored-By: Claude Opus 5.5 --- .../src/handlers/attribute_emitters.lua | 51 ++++++----- .../test/test_hue_light_color_temperature.lua | 91 +++++++++++++++++++ 2 files changed, 119 insertions(+), 23 deletions(-) create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua diff --git a/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua b/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua index f0b3229f24..f2e47fbb2c 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/attribute_emitters.lua @@ -60,37 +60,42 @@ local function _emit_light_events_inner(light_device, light_repr) if light_repr.color_temperature.mirek_valid then mirek = light_repr.color_temperature.mirek end - local mirek_schema = light_repr.color_temperature.mirek_schema or { - mirek_minimum = Consts.DEFAULT_MIN_MIREK, - mirek_maximum = Consts.DEFAULT_MAX_MIREK - } - -- See note in `src/handlers/lifecycle_handlers/light.lua` about min/max relationship -- if the below is not intuitive. local color_temp_range = light_device:get_latest_state("main", capabilities.colorTemperature.ID, capabilities.colorTemperature.colorTemperatureRange.NAME); local min_kelvin = (color_temp_range and color_temp_range.minimum) - local api_min_kelvin = math.floor(utils.mirek_to_kelvin(mirek_schema.mirek_maximum, Consts.KELVIN_STEP_SIZE) or Consts.MIN_TEMP_KELVIN_COLOR_AMBIANCE) local max_kelvin = (color_temp_range and color_temp_range.maximum) - local api_max_kelvin = math.floor(utils.mirek_to_kelvin(mirek_schema.mirek_minimum, Consts.KELVIN_STEP_SIZE) or Consts.MAX_TEMP_KELVIN) - local update_range = false - if min_kelvin ~= api_min_kelvin then - update_range = true - min_kelvin = api_min_kelvin - light_device:set_field(Fields.MIN_KELVIN, min_kelvin, { persist = true }) - end + -- Updates (e.g. over SSE) usually carry no `mirek_schema`; keep the range learned from the + -- full resource rather than resetting it to the defaults. + local mirek_schema = light_repr.color_temperature.mirek_schema + if mirek_schema or not (min_kelvin and max_kelvin) then + mirek_schema = mirek_schema or { + mirek_minimum = Consts.DEFAULT_MIN_MIREK, + mirek_maximum = Consts.DEFAULT_MAX_MIREK + } + local api_min_kelvin = math.floor(utils.mirek_to_kelvin(mirek_schema.mirek_maximum, Consts.KELVIN_STEP_SIZE) or Consts.MIN_TEMP_KELVIN_COLOR_AMBIANCE) + local api_max_kelvin = math.floor(utils.mirek_to_kelvin(mirek_schema.mirek_minimum, Consts.KELVIN_STEP_SIZE) or Consts.MAX_TEMP_KELVIN) + + local update_range = false + if min_kelvin ~= api_min_kelvin then + update_range = true + min_kelvin = api_min_kelvin + light_device:set_field(Fields.MIN_KELVIN, min_kelvin, { persist = true }) + end - if max_kelvin ~= api_max_kelvin then - update_range = true - max_kelvin = api_max_kelvin - light_device:set_field(Fields.MAX_KELVIN, max_kelvin, { persist = true }) - end + if max_kelvin ~= api_max_kelvin then + update_range = true + max_kelvin = api_max_kelvin + light_device:set_field(Fields.MAX_KELVIN, max_kelvin, { persist = true }) + end - if update_range then - light_device.log.debug(st_utils.stringify_table({ minimum = min_kelvin, maximum = max_kelvin }, "updating color temp range")); - light_device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ minimum = min_kelvin, maximum = max_kelvin })) - else - light_device.log.debug(st_utils.stringify_table(color_temp_range, "color temp range unchanged")); + if update_range then + light_device.log.debug(st_utils.stringify_table({ minimum = min_kelvin, maximum = max_kelvin }, "updating color temp range")); + light_device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ minimum = min_kelvin, maximum = max_kelvin })) + else + light_device.log.debug(st_utils.stringify_table(color_temp_range, "color temp range unchanged")); + end end -- In xy colour mode the bridge reports `mirek_valid = false` (and `mirek = null`); there is diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua new file mode 100644 index 0000000000..04999bc63d --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua @@ -0,0 +1,91 @@ +--- The colour temperature range of a light whose range isn't the driver's default. + +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 = "55555555-5555-5555-5555-555555555555" +local HUE_DEVICE_ID = "55555555-5555-5555-5555-555555555556" +local ZIGBEE_RID = "55555555-5555-5555-5555-555555555557" + +-- A white ambiance lamp reaching down to 454 mirek (2200K), rather than the default 500 (2000K). +local COLOR_TEMPERATURE = { mirek = 366, mirek_valid = true, mirek_schema = { mirek_minimum = 153, mirek_maximum = 454 } } + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LIGHT_RID, { + on = { on = true }, + dimming = { brightness = 100 }, + color_temperature = COLOR_TEMPERATURE, + hue_device_id = HUE_DEVICE_ID, + }, "white-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 refreshes injected by the light's added and init handlers. +hue_test_helpers.expect_device_info(rest, HUE_DEVICE_ID, + { { rtype = "zigbee_connectivity", rid = ZIGBEE_RID } }, { reusable = true }) +hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID, { owner = HUE_DEVICE_ID, reusable = true }) +http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. LIGHT_RID, { + body = { errors = {}, data = { { + id = LIGHT_RID, + type = "light", + owner = { rid = HUE_DEVICE_ID, rtype = "device" }, + on = { on = true }, + dimming = { brightness = 100 }, + color_temperature = COLOR_TEMPERATURE, + } } }, + times = 2, +}) +http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + +local function expect_refresh_events(first_refresh) + local events = { + capabilities.switch.switch.on(), + capabilities.switchLevel.level(100), + capabilities.colorTemperature.colorTemperature(2732), + } + if first_refresh then + table.insert(events, 1, capabilities.colorTemperature.colorTemperatureRange({ minimum = 2200, maximum = 6500 })) + end + for _, event in ipairs(events) do + test.socket.capability:__expect_send(mock_light:generate_test_message("main", event)) + end +end + +test.set_test_init_function(function() + scenario:activate() + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + body = { errors = {}, data = { { owner = { rid = "unrelated-device-not-in-fixture" }, status = "connected" } } }, + }) + test.socket.capability:__set_channel_ordering("relaxed") + expect_refresh_events(true) + fixtures.test_init() + expect_refresh_events(false) +end) + +test.register_coroutine_test( + "a colour temperature update without the light's mirek range keeps the range it has", + function() + test.wait_for_events() + + -- Updates over SSE carry the new mirek but not the light's `mirek_schema`. Any colour + -- temperature range event (e.g. back to the default 2000K-6500K) would fail the test. + http.queue_sse_event(sse, { + hue_test_helpers.light_event(LIGHT_RID, true, nil, { color_temperature = { mirek = 250, mirek_valid = true } }), + }) + 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.colorTemperature.colorTemperature(4000))) + test.wait_for_events() + end +) + +test.run_registered_tests() From 5190102b7da242977f53ed75d3f92c898da4a4d6 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 08:33:32 -0500 Subject: [PATCH 07/22] 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 | 49 +++++++++++++++++++ 2 files changed, 66 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 606ea0927e..7063eb2d21 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,52 @@ register_batch_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 + +register_batch_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, { dimming = { brightness = 23 } }) + + 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) + 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 +) + +register_batch_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, { on = { on = true } }) + expect_grouped_light_put(GROUPED_LIGHT_RID, { dimming = { brightness = 23 } }) + + 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) + end +) + test.run_registered_tests() From 03c1c96c234d6c2ec1b1b1e9641017bcec7910cd Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 08:34:01 -0500 Subject: [PATCH 08/22] philips-hue: Fix light connectivity tracking and repeated outage reports - 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 do. - 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 an outage once and re-arm when the stream reconnects. Tests cover per-light connectivity changes from recorded payloads, an outage where the bridge is unreachable for two reconnect attempts, one where it answers them with 503, and a second outage after recovery. Co-Authored-By: Claude Opus 5.5 --- .../src/handlers/lifecycle_handlers/light.lua | 3 + .../src/test/test_hue_device_connectivity.lua | 211 ++++++++++++++++++ .../src/utils/hue_bridge_utils.lua | 6 + 3 files changed, 220 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..e57f38d8d5 --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua @@ -0,0 +1,211 @@ +--- Tests for online/offline status: per-light Zigbee connectivity reported over SSE, and losing the +--- bridge's event stream. Built from payloads recorded on real hardware (see +--- test/fixtures/capture_payloads.lua). + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local mock_devices_api = require "integration_test.mock_devices_api" +local hue_test_helpers = require "test.hue_test_helpers" +local http = require "integration_test.connection_scenario_http" +local capture_payloads = require "test.fixtures.capture_payloads" + +local LAMP_RID = capture_payloads.ids.color_lamp_light +local LAMP_DEVICE_ID = capture_payloads.ids.color_lamp_device + +local OTHER_RID = "33333333-3333-3333-3333-333333333333" +local OTHER_DEVICE_ID = "other-light-device" +local OTHER_ZIGBEE_RID = "other-light-zigbee" + +local function lamp_light_resource() + local resp = capture_payloads.get("color_lamp_light") + return resp +end + +local lamp_repr = lamp_light_resource().data[1] + +local fixtures = hue_test_helpers.HueDeviceBuilder.new() + :with_bridge() + :with_light(LAMP_RID, { + on = lamp_repr.on, + dimming = lamp_repr.dimming, + color = lamp_repr.color, + color_temperature = lamp_repr.color_temperature, + hue_device_id = LAMP_DEVICE_ID, + label = lamp_repr.metadata.name, + }, "white-and-color-ambiance.yml") + :with_light(OTHER_RID, { on = { on = true }, dimming = { brightness = 80 }, hue_device_id = OTHER_DEVICE_ID }, + "white.yml") + :enable_sse() + :start() + +local mock_bridge = fixtures.bridge +local mock_lamp, mock_other = fixtures.devices[1], fixtures.devices[2] + +local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local rest, sse = conns.rest, conns.sse + +-- The events emitted for the recorded lamp's light resource on a refresh. The colour temperature +-- range is only emitted when it changes, i.e. on the first refresh. +local function expect_lamp_state_events(first_refresh) + if first_refresh then + test.socket.capability:__expect_send(mock_lamp:generate_test_message("main", + capabilities.colorTemperature.colorTemperatureRange({ minimum = 2000, maximum = 6500 }))) + end + for _, event in ipairs({ + capabilities["samsungim.hueSyncMode"].mode("normal"), + capabilities.switch.switch.on(), + capabilities.switchLevel.level(100), + capabilities.colorTemperature.colorTemperature(2732), + capabilities.colorControl.hue(11), + capabilities.colorControl.saturation(53), + }) do + test.socket.capability:__expect_send(mock_lamp:generate_test_message("main", event)) + end +end + +local function expect_other_state_events() + test.socket.capability:__expect_send(mock_other:generate_test_message("main", capabilities.switch.switch.on())) + test.socket.capability:__expect_send(mock_other:generate_test_message("main", capabilities.switchLevel.level(80))) +end + +-- Recorded REST responses for the lamp, and generic ones for the other light; reusable so they +-- serve every refresh. +http.expect_request(rest, "GET", "/clip/v2/resource/device/" .. LAMP_DEVICE_ID, { + status = 200, body = capture_payloads.get("color_lamp_device"), reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity/" .. capture_payloads.ids.color_lamp_zigbee, { + status = 200, body = capture_payloads.get("color_lamp_zigbee"), reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. LAMP_RID, { + status = 200, body = lamp_light_resource(), reusable = true, +}) +hue_test_helpers.expect_device_info(rest, OTHER_DEVICE_ID, + { { rtype = "zigbee_connectivity", rid = OTHER_ZIGBEE_RID } }, { reusable = true }) +hue_test_helpers.expect_zigbee_connectivity(rest, OTHER_ZIGBEE_RID, { owner = OTHER_DEVICE_ID, reusable = true }) +hue_test_helpers.expect_light_resource(rest, OTHER_RID, true, 80, { reusable = true }) + +http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + +test.set_test_init_function(function() + scenario:activate() + -- Connectivity poll after the event stream opens. + http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + status = 200, + body = { errors = {}, data = {} }, + reusable = true, + persistent = false, + }) + + test.socket.capability:__set_channel_ordering("relaxed") + expect_lamp_state_events(true) -- .added's injected refresh + expect_other_state_events() + fixtures.test_init() + expect_lamp_state_events(false) -- .init's injected refresh + expect_other_state_events() +end) + +--- Let the driver run until `predicate` holds (online/offline changes emit no capability events +--- for `wait_for_events` to wait on). +local function wait_until(predicate) + test.wait_until(predicate, { message = "the expected online/offline state" }) +end + +local function is_online(mock_device) + return mock_devices_api.__is_device_online(mock_device.id) +end + +local function assert_online(mock_device, expected, what) + assert(mock_devices_api.__is_device_online(mock_device.id) == expected, + string.format("expected %s to be %s", what, expected and "online" or "offline")) +end + +--- Assert how many times the bridge and each light were marked offline since the history was +--- last cleared. +local function assert_offline_calls(expected) + for _, entry in ipairs({ { mock_bridge, "bridge" }, { mock_lamp, "lamp" }, { mock_other, "other light" } }) do + local count = mock_devices_api.__count_device_status_calls(entry[1].id, false) + assert(count == expected, + string.format("expected the %s to be marked offline %d time(s), got %d", entry[2], expected, count)) + end +end + +test.register_coroutine_test( + "a Zigbee connectivity issue for one light marks only that light offline", + function() + test.wait_for_events() + assert_online(mock_lamp, true, "the lamp") + + http.queue_sse_event(scenario:get_connection("sse"), capture_payloads.get("color_lamp_connectivity_issue")) + wait_until(function() return is_online(mock_lamp) == false end) + + assert_online(mock_lamp, false, "the lamp") + assert_online(mock_other, true, "the other light") + assert_online(mock_bridge, true, "the bridge") + end +) + +test.register_coroutine_test( + "a light whose Zigbee connection comes back is marked online and refreshed", + function() + test.wait_for_events() + local sse_conn = scenario:get_connection("sse") + http.queue_sse_event(sse_conn, capture_payloads.get("color_lamp_connectivity_issue")) + wait_until(function() return is_online(mock_lamp) == false end) + assert_online(mock_lamp, false, "the lamp") + + http.queue_sse_event(sse_conn, capture_payloads.get("color_lamp_connected")) + expect_lamp_state_events(false) + test.wait_for_events() + + assert_online(mock_lamp, true, "the lamp") + end +) + +test.register_coroutine_test( + "while the event stream keeps failing to reconnect, devices are only marked offline once", + function() + test.wait_for_events() + assert_online(mock_bridge, true, "the bridge") + + mock_devices_api.__clear_device_status_history() + -- The bridge drops the event stream and is unreachable for the next two reconnect attempts, + -- as in physical test scenarios 8 and 9 ("No route to host" every few seconds). + scenario:fail_connects(2, "No route to host") + scenario:get_connection("sse"):close_connection() + wait_until(function() return is_online(mock_bridge) == false end) + wait_until(function() return is_online(mock_bridge) == true end) + assert_offline_calls(1) + assert_online(mock_bridge, true, "the bridge") + assert(mock_devices_api.__count_device_status_calls(mock_bridge.id, true) == 1, + "expected the bridge to be marked online once, when the stream reconnected") + + -- Once the stream has reconnected, a later outage is reported again. + scenario:get_connection("sse"):close_connection() + wait_until(function() return is_online(mock_bridge) == false end) + assert_offline_calls(2) + wait_until(function() return is_online(mock_bridge) == true end) + + end +) + +test.register_coroutine_test( + "while the bridge rejects event stream reconnects, devices are only marked offline once", + function() + test.wait_for_events() + mock_devices_api.__clear_device_status_history() + + -- The bridge drops the stream and answers the next two reconnects with 503 (e.g. while it + -- restarts). + local reconnects = http.expect_request(sse, "GET", "/eventstream/clip/v2", { defer = true, times = 2 }) + scenario:get_connection("sse"):close_connection() + wait_until(function() return is_online(mock_bridge) == false end) + reconnects:await():respond({ status = 503 }) + reconnects:await():respond({ status = 503 }) + + -- The third reconnect is answered by the file's handshake, and the stream is back. + wait_until(function() return is_online(mock_bridge) == true end) + assert_offline_calls(1) + 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 0b49acb770..0a8fdf80e9 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]] @@ -127,6 +131,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 c5012e4fe73c024dd639da3994d5ead4e22f9000 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 09:40:37 -0500 Subject: [PATCH 09/22] philips-hue: Re-report a child offline if it came back during an outage The event stream's onerror handler only reported devices offline on the first failure of an outage, using one flag for the bridge and all its children. A child that comes back online during the outage, e.g. because a refresh reaches it over REST, then stayed online through later failed reconnects. Keep the once-per-outage guard for the bridge, which only comes back online when the stream reopens, and decide per child from its IS_ONLINE field instead, so only children that aren't already offline are reported. The repeated-outage test now has the reconnect's connectivity scan report the lights connected, so the second outage is reported because the lights really came back. Co-Authored-By: Claude Opus 5.5 --- .../src/test/test_hue_device_connectivity.lua | 64 ++++++++++++++++++- .../src/utils/hue_bridge_utils.lua | 24 ++++--- 2 files changed, 76 insertions(+), 12 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 e57f38d8d5..1315e53a62 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 @@ -119,6 +119,30 @@ local function assert_online(mock_device, expected, what) string.format("expected %s to be %s", what, expected and "online" or "offline")) end +--- Answer the connectivity scan the driver runs when the event stream (re)opens with both lights +--- connected, so they are marked online again (and refreshed) after an outage. +local function report_lights_connected_on_reconnect() + http.expect_request_for_test(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + status = 200, + body = { + errors = {}, + data = { + { type = "zigbee_connectivity", id = capture_payloads.ids.color_lamp_zigbee, status = "connected", + owner = { rid = LAMP_DEVICE_ID, rtype = "device" } }, + { type = "zigbee_connectivity", id = OTHER_ZIGBEE_RID, status = "connected", + owner = { rid = OTHER_DEVICE_ID, rtype = "device" } }, + }, + }, + reusable = true, + prepend = true, + }) +end + +local function expect_reconnect_refresh_events() + expect_lamp_state_events(false) + expect_other_state_events() +end + --- Assert how many times the bridge and each light were marked offline since the history was --- last cleared. local function assert_offline_calls(expected) @@ -166,6 +190,7 @@ test.register_coroutine_test( function() test.wait_for_events() assert_online(mock_bridge, true, "the bridge") + report_lights_connected_on_reconnect() mock_devices_api.__clear_device_status_history() -- The bridge drops the event stream and is unreachable for the next two reconnect attempts, @@ -173,18 +198,53 @@ test.register_coroutine_test( scenario:fail_connects(2, "No route to host") scenario:get_connection("sse"):close_connection() wait_until(function() return is_online(mock_bridge) == false end) + expect_reconnect_refresh_events() 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") + wait_until(function() return is_online(mock_lamp) and is_online(mock_other) end) + test.wait_for_events() - -- Once the stream has reconnected, a later outage is reported again. + -- Once the stream has reconnected and the lights are back, 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) + expect_reconnect_refresh_events() wait_until(function() return is_online(mock_bridge) == true end) + wait_until(function() return is_online(mock_lamp) and is_online(mock_other) end) + test.wait_for_events() + end +) + +test.register_coroutine_test( + "a light refreshed back online during an outage is marked offline again on the next failed reconnect", + function() + test.wait_for_events() + mock_devices_api.__clear_device_status_history() + + -- The bridge drops the stream and rejects the next two reconnects, while its REST API still + -- answers. The third reconnect is answered by the file's handshake. + local reconnects = http.expect_request(sse, "GET", "/eventstream/clip/v2", { defer = true, times = 2 }) + scenario:get_connection("sse"):close_connection() + reconnects:await():respond({ status = 503 }) + wait_until(function() return is_online(mock_lamp) == false end) + assert(mock_devices_api.__count_device_status_calls(mock_lamp.id, false) == 1, + "expected the lamp to be marked offline when the stream dropped") + + -- Between reconnects, a refresh reaches the lamp over REST and finds its Zigbee connection fine. + test.socket.capability:__queue_receive({ mock_lamp.id, { capability = "refresh", component = "main", command = "refresh", args = {} } }) + expect_lamp_state_events(false) + wait_until(function() return is_online(mock_lamp) == true end) + -- The next failed reconnect reports it offline again. + reconnects:await():respond({ status = 503 }) + wait_until(function() return is_online(mock_lamp) == false end) + assert(mock_devices_api.__count_device_status_calls(mock_lamp.id, false) == 2, + "expected the lamp to be marked offline again") + assert(mock_devices_api.__count_device_status_calls(mock_bridge.id, false) == 1, + "expected the bridge to be reported offline once for the outage") + wait_until(function() return is_online(mock_bridge) == true end) 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 0a8fdf80e9..69cd11f8aa 100644 --- a/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua @@ -41,14 +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 + -- Whether the bridge's current outage has already been reported; the EventSource calls + -- `onerror` on every failed reconnect attempt. + local bridge_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_marked_offline = false bridge_device:online() local bridge_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] @@ -131,15 +131,19 @@ 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 - + -- Only report devices that aren't already offline. A child can come back online during the + -- outage (e.g. a refresh reaches it over REST), so it's checked again on every failure. for _, device_record in ipairs(bridge_device:get_child_list()) do - device_record:set_field(Fields.IS_ONLINE, false) - device_record:offline() + if device_record:get_field(Fields.IS_ONLINE) ~= false then + device_record:set_field(Fields.IS_ONLINE, false) + device_record:offline() + end end - bridge_device:offline() + if not bridge_marked_offline then + bridge_marked_offline = true + bridge_device:offline() + end end eventsource.onmessage = function(msg) From d5375da30e692cd329568b3655f494433aeaa700 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 09:41:58 -0500 Subject: [PATCH 10/22] philips-hue: Log errors the bridge returns for grouped light commands The grouped light command handlers only logged when a response had an empty errors list (`#resp.errors == 0`), so errors the bridge actually returned for a room or zone command were never logged. Use one helper, like the individual light commands do, that logs both a request error and each error in the response. Co-Authored-By: Claude Opus 5.5 --- .../src/handlers/grouped_light_commands.lua | 67 ++++++------------- .../src/test/test_hue_batch_commands.lua | 41 ++++++++++++ 2 files changed, 63 insertions(+), 45 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 30b06cbcfc..3c45df75f2 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua @@ -11,6 +11,23 @@ local utils = require "utils" ---@class GroupedLightCommandHandlers local GroupedLightCommandHandlers = {} +--- Log the error from a failed request, and any errors the bridge returned in its response. +---@param resp table? +---@param err string? +---@param action_desc string +local function log_response_errors(resp, err, action_desc) + if err ~= nil then + log.error_with({ hub_logs = true }, "Error performing " .. action_desc .. ": " .. err) + end + + if resp and resp.errors and #resp.errors > 0 then + for _, error in ipairs(resp.errors) do + log.error_with({ hub_logs = true }, + "Error returned in Hue response for " .. action_desc .. ": " .. tostring(error.description)) + end + end +end + ---@param driver HueDriver ---@param bridge_device HueBridgeDevice ---@param group table @@ -33,15 +50,7 @@ local function do_switch_action(driver, bridge_device, group, args) end local resp, err = hue_api:set_grouped_light_on_state(grouped_light_id, on) - 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 + log_response_errors(resp, err, "on/off action") end ---@param driver HueDriver @@ -75,27 +84,11 @@ local function do_switch_level_action(driver, bridge_device, group, args) 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 + log_response_errors(resp, err, "on/off action") end local resp, err = hue_api:set_grouped_light_level(grouped_light_id, level) - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing switch level 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 + log_response_errors(resp, err, "switch level action") end @@ -129,15 +122,7 @@ local function do_color_action(driver, bridge_device, group, args, aux) local xy = HueColorUtils.safe_rgb_to_xy(red, green, blue, aux[Fields.GAMUT]) local resp, err = hue_api:set_grouped_light_color_xy(grouped_light_id, xy) - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing color 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 + log_response_errors(resp, err, "color action") end ---@param driver HueDriver @@ -200,15 +185,7 @@ local function do_color_temp_action(driver, bridge_device, group, args, aux) local resp, err = hue_api:set_grouped_light_color_temp(grouped_light_id, mirek) - if not resp or (resp.errors and #resp.errors == 0) then - if err ~= nil then - log.error_with({ hub_logs = true }, "Error performing color temp 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 + log_response_errors(resp, err, "color temp action") grouped_utils.set_field_on_group_devices(group, Fields.COLOR_TEMP_SETPOINT, clamped_kelvin); 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 7063eb2d21..c5652bf24f 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 @@ -257,4 +257,45 @@ register_batch_test( end ) +--- Run `fn`, returning the messages logged with `log.error_with` while it ran. +local function capture_logged_errors(fn) + local log = require "log" + local original = log.error_with + local messages = {} + log.error_with = function(opts, ...) + table.insert(messages, table.concat({ ... }, " ")) + return original(opts, ...) + end + local ok, err = pcall(fn) + log.error_with = original + if not ok then error(err, 0) end + return messages +end + +register_batch_test( + "errors the bridge returns for a grouped command are logged", + function() + init_and_seed_groups() + http.expect_request_for_test(rest, "PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, { + status = 503, + body = { errors = { { description = "bridge is busy" } }, data = {} }, + }) + + local messages = capture_logged_errors(function() + test.socket.capability:__queue_receive_batch({ + { light_1.id, switch_cmd("on") }, + { light_2.id, switch_cmd("on") }, + }) + test.wait_for_events() + end) + + assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 1) + local logged = false + for _, message in ipairs(messages) do + if message:find("bridge is busy", 1, true) then logged = true end + end + assert(logged, "expected the bridge's error to be logged, got: " .. table.concat(messages, " | ")) + end +) + test.run_registered_tests() From 4d6d9f427a3c5cb386dd3e3f40cb6c94f458b362 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 09:42:25 -0500 Subject: [PATCH 11/22] philips-hue: Turn a group on before setLevel if a light's state is unknown A grouped setLevel only sent the "on" request first when one of the group's lights was known to be off, so a light whose switch state hadn't been reported yet counted as on. Setting the brightness of a grouped light doesn't turn its lights on, so such a light could stay off. Treat any light not known to be on as off. Co-Authored-By: Claude Opus 5.5 --- .../src/handlers/grouped_light_commands.lua | 5 +++-- .../src/test/test_hue_batch_commands.lua | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 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 3c45df75f2..c45e3a10e9 100644 --- a/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua +++ b/drivers/SmartThings/philips-hue/src/handlers/grouped_light_commands.lua @@ -74,10 +74,11 @@ local function do_switch_level_action(driver, bridge_device, group, args) return end - -- As with an individual command, only turn the group on first if one of its lights is off. + -- Only turn the group on first if one of its lights is off. A light whose state hasn't been + -- reported yet counts as off: setting the brightness of a light that is off doesn't turn it on. local any_off = false for _, device in ipairs(group.devices or {}) do - if device:get_field(Fields.SWITCH_STATE) == "off" then + if device:get_field(Fields.SWITCH_STATE) ~= "on" then any_off = true break 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 c5652bf24f..91f87fc93c 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 @@ -257,6 +257,25 @@ register_batch_test( end ) +register_batch_test( + "a grouped setLevel turns the room on first when a light's state is not known yet", + function() + init_and_seed_groups() + set_switch_state(light_1, "on") + set_switch_state(light_2, nil) + expect_grouped_light_put(GROUPED_LIGHT_RID, { on = { on = true } }) + expect_grouped_light_put(GROUPED_LIGHT_RID, { dimming = { brightness = 23 } }) + + 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) + end +) + --- Run `fn`, returning the messages logged with `log.error_with` while it ran. local function capture_logged_errors(fn) local log = require "log" From 23771ab2472f2df628495389dc850a48f85ab48d Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 09:44:28 -0500 Subject: [PATCH 12/22] philips-hue: Stop retrying bridge scans once they are no longer needed The connectivity scan started when the event stream opens, and the room and zone scan, retry until they succeed. Nothing stopped them in the meantime: - If the bridge was removed, they kept calling its shut-down API client, which crashes the task. The group scan task also touched the removed device afterwards. - If the stream reconnected, its new connection started a second connectivity scan alongside the old one. Stop the connectivity scan once its stream connection is no longer the bridge's current one: it reconnected, was replaced after an IP change, or the bridge was removed. Stop the group scan once the bridge was removed, and don't touch the removed bridge afterwards. Co-Authored-By: Claude Opus 5.5 --- .../test/test_hue_bridge_connect_scans.lua | 50 +++++++++++++++++++ .../philips-hue/src/utils/grouped_utils.lua | 16 +++++- .../src/utils/hue_bridge_utils.lua | 19 +++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua index 6fd6f5d030..d23260f491 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua @@ -99,4 +99,54 @@ test.register_coroutine_test( } ) +--- Let mock time run on for `seconds`, well past the driver's longest backoff. +local function let_time_pass(seconds) + local until_time = os.time() + seconds + test.wait_until(function() return os.time() >= until_time end, + { max_turns = 500, message = string.format("%ds of mock time passing", seconds) }) +end + +local function remove_bridge() + test.socket.device_lifecycle:__queue_receive({ fixtures.bridge.id, "removed" }) + test.wait_for_events() +end + +test.register_coroutine_test( + "a failing connectivity scan stops retrying once the bridge is removed", + function() + answer_next({ status = 500, body = FAILED }) + answer_next({ status = 500, body = FAILED }) + remove_bridge() + -- Another attempt would be an unexpected request. + let_time_pass(120) + end, + { test_init = answer_connectivity_scans(2) } +) + +test.register_coroutine_test( + "a failing room scan stops retrying once the bridge is removed", + function() + answer_next({ status = 500, body = FAILED }) + answer_next({ status = 500, body = FAILED }) + remove_bridge() + let_time_pass(120) + end, + { test_init = answer_room_scans(2) } +) + +test.register_coroutine_test( + "a failing connectivity scan is superseded by the scan of the next event stream connection", + function() + answer_next({ status = 500, body = FAILED }) + answer_next({ status = 500, body = FAILED }) + -- The stream drops and reconnects; the new connection's scan succeeds, and the room scan follows. + sse:close_connection() + answer_next({ body = CONNECTED }) + http.await_request(rest, "GET", ROOM_PATH, { max_turns = 500 }):respond({ body = EMPTY }) + -- A further attempt by the earlier connection's scan would be an unexpected request. + let_time_pass(120) + end, + { test_init = answer_connectivity_scans(3) } +) + 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 8b6e09ee32..07a7fd5d27 100644 --- a/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/grouped_utils.lua @@ -148,6 +148,12 @@ function grouped_utils.scan_groups(driver, bridge_device, api, hue_id_to_device) end if rooms and zones then break end cosock.socket.sleep(backoff()) + -- The bridge was removed while the scan was retrying: a removed device's fields all read as + -- nil, and removing a bridge shuts down and clears its API client. + if bridge_device.id == nil or bridge_device:get_field(Fields.BRIDGE_API) ~= api then + log.info("Stopping group scan, the bridge's API client is gone") + return + end end -- Combine rooms and zones. for _, zone in ipairs(zones) do @@ -303,6 +309,12 @@ function grouped_utils.queue_group_scan(driver, bridge_device) end end + -- A removed device's fields and methods all read as nil. + if bridge_device.id == nil then + log.info("Bridge was removed, skipping group scan") + return + end + -- Finally do the scan local hue_device_table = build_hue_id_to_device_map(bridge_device) local api = bridge_device:get_field(Fields.BRIDGE_API) @@ -317,7 +329,9 @@ function grouped_utils.queue_group_scan(driver, bridge_device) grouped_utils.scan_groups(driver, bridge_device, api, hue_device_table) -- Clear out tx for the next request - bridge_device:set_field(Fields.GROUPS_SCAN_QUEUE, nil) + if bridge_device.id ~= nil then + bridge_device:set_field(Fields.GROUPS_SCAN_QUEUE, nil) + end end) -- Set the queue to the tx bridge_device:set_field(Fields.GROUPS_SCAN_QUEUE, tx) 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 69cd11f8aa..4e73a316be 100644 --- a/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua +++ b/drivers/SmartThings/philips-hue/src/utils/hue_bridge_utils.lua @@ -44,12 +44,26 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u -- Whether the bridge's current outage has already been reported; the EventSource calls -- `onerror` on every failed reconnect attempt. local bridge_marked_offline = false + -- Counts the times the event stream has opened, so a connectivity scan started for an earlier + -- connection can tell it has been superseded. + local connection_count = 0 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)) bridge_marked_offline = false bridge_device:online() + connection_count = connection_count + 1 + local this_connection = connection_count + -- Whether this scan's connection is still the bridge's current one: the stream may have + -- reconnected (and started a new scan), been replaced after an IP change, or the bridge may + -- have been removed. + local function connection_is_current() + -- A removed device's fields all read as nil. + return this_connection == connection_count + and bridge_device.id ~= nil + and bridge_device:get_field(Fields.EVENT_SOURCE) == eventsource + end local bridge_api = bridge_device:get_field(Fields.BRIDGE_API) --[[@as PhilipsHueApi]] cosock.spawn(function() @@ -124,6 +138,11 @@ function hue_bridge_utils.do_bridge_network_init(driver, bridge_device, bridge_u if scanned then break end cosock.socket.sleep(backoff()) + if not connection_is_current() then + log.info(string.format("Stopping connectivity scan for %s, its event stream connection is gone", + tostring(bridge_device.label))) + return + end end grouped_utils.queue_group_scan(driver, bridge_device) end, string.format("Hue Bridge %s On Connect Task", bridge_device.label)) From 8944cfdf2f06294d620bd6ecd71912938a44986e Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 09:45:14 -0500 Subject: [PATCH 13/22] philips-hue: Add copyright headers to the LAN integration tests Co-Authored-By: Claude Opus 5.5 --- .../philips-hue/src/test/fixtures/capture_payloads.lua | 3 +++ drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua | 3 +++ .../philips-hue/src/test/test_hue_batch_commands.lua | 3 +++ .../philips-hue/src/test/test_hue_bridge_connect_scans.lua | 3 +++ .../philips-hue/src/test/test_hue_bridge_discovery.lua | 3 +++ .../philips-hue/src/test/test_hue_bridge_resource_events.lua | 3 +++ .../SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua | 3 +++ .../philips-hue/src/test/test_hue_button_lifecycle.lua | 3 +++ .../SmartThings/philips-hue/src/test/test_hue_button_sse.lua | 3 +++ .../philips-hue/src/test/test_hue_child_device_lifecycle.lua | 3 +++ .../philips-hue/src/test/test_hue_contact_sensor_sse.lua | 3 +++ .../philips-hue/src/test/test_hue_device_connectivity.lua | 3 +++ .../philips-hue/src/test/test_hue_driver_restart.lua | 3 +++ .../philips-hue/src/test/test_hue_error_handling.lua | 3 +++ .../SmartThings/philips-hue/src/test/test_hue_light_color.lua | 3 +++ .../philips-hue/src/test/test_hue_light_color_temperature.lua | 3 +++ .../philips-hue/src/test/test_hue_light_commands.lua | 3 +++ .../philips-hue/src/test/test_hue_light_refresh.lua | 3 +++ .../philips-hue/src/test/test_hue_motion_sensor_sse.lua | 3 +++ .../philips-hue/src/test/test_hue_multibutton_sse.lua | 3 +++ drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua | 3 +++ 21 files changed, 63 insertions(+) diff --git a/drivers/SmartThings/philips-hue/src/test/fixtures/capture_payloads.lua b/drivers/SmartThings/philips-hue/src/test/fixtures/capture_payloads.lua index 32169b3c96..c6fb3b0d6e 100644 --- a/drivers/SmartThings/philips-hue/src/test/fixtures/capture_payloads.lua +++ b/drivers/SmartThings/philips-hue/src/test/fixtures/capture_payloads.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- Real Hue bridge payloads recorded by the instrumented driver during physical testing. --- Identifiers (resource UUIDs, bridge ids, MACs, IPs, the Zigbee PAN id and v1 scene ids) are --- replaced with stable fakes; everything else (names, timestamps, versions, state) is as 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 6a48534496..605b806442 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local t_utils = require "integration_test.utils" local connection_scenario = require "integration_test.connection_scenario" 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 91f87fc93c..b96d7972ce 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 @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- 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. diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua index d23260f491..a718321446 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- The scans the driver makes once the bridge's event stream connects: the Zigbee connectivity of --- its devices, followed by a (debounced) scan of its rooms and zones. 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 7518ba8193..ed3fb72aa6 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 @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local connection_scenario = require "integration_test.connection_scenario" local http = require "integration_test.connection_scenario_http" 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 1ed3f51f67..fcdd927fc2 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 @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- 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. 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 index ce347e2bd3..778322e7ce 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- The bridge's SSE event stream: connecting marks the bridge online, update/add/delete events --- reach the right devices, and a dropped stream marks everything offline until it reconnects. --- 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 1f1bd9fe4d..e7effc410f 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,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- A button whose state is in the discovery cache is added and initialized without any REST --- requests (no address is registered, so any connection attempt would fail the test). 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 b630ae628f..4c2a285752 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,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- Test for Hue button device with SSE events. --- Uses HueDeviceBuilder and ConnectionScenario 2.0 with device-type-specific helper. 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 86250bbb54..1b5c7a0da8 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,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- A child device whose resource state isn't in the discovery cache is treated as a "stray": --- it isn't onboarded (no REST state fetch, no device fields set) until the bridge reports it. 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 8ede32d4be..0027efad72 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 @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" 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 1315e53a62..e11ae1d711 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,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- 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). 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 1f4d6cffca..d4fea838b9 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 @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- 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. 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 b41e7b1769..7119a4452b 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,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- Error handling in Hue light commands and refresh: error statuses, Hue API errors, missing --- services, unparseable responses, and a keep-alive connection the bridge has closed. --- 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 2fb64fd2de..50ed8ccdb1 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 @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- Tests for colour state reported by the bridge, built from payloads recorded on real hardware --- (see test/fixtures/capture_payloads.lua). diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua index 04999bc63d..bebdc83c70 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- The colour temperature range of a light whose range isn't the driver's default. local test = require "integration_test" 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 ae8f7ae377..6859f537c6 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,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- Test for Hue light command handling (switch, level, color, temperature). --- Uses ConnectionScenario 2.0 for PUT command testing. 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 3ec779bd2e..63cfdcab3f 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 @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" 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 21f7f69a66..7b55e46131 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 @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" 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 ce015948da..f1bdcf6039 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 @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" 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 5e562717c9..f54fda0832 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua @@ -1,3 +1,6 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + --- 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`. From 36ccf2afd18aff372a8e41bfb5b4c4cee7acddf0 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 09:46:29 -0500 Subject: [PATCH 14/22] philips-hue: Make the test SSE payloads match the bridge's - Battery levels now come from a separate device_power update, as real bridges send them, rather than a power_state on the button, motion or contact resource. Combined updates put each changed resource in the same event with the new combined_event helper. - Drop the contact event's temperature option and the motion event's temperature and light level options, which modelled nothing real; the motion test now sends a temperature resource update instead. - light_event can leave out `on`, since a real dimming update carries only `dimming`. Add a test for a dimming-only update. - The discovery test's link-button response uses `clientkey`, the field name the API returns. Co-Authored-By: Claude Opus 5.5 --- .../philips-hue/src/test/hue_test_helpers.lua | 90 ++++++++++--------- .../src/test/test_hue_bridge_discovery.lua | 2 +- .../src/test/test_hue_bridge_sse.lua | 16 ++++ .../src/test/test_hue_button_sse.lua | 10 ++- .../src/test/test_hue_contact_sensor_sse.lua | 7 +- .../src/test/test_hue_motion_sensor_sse.lua | 12 +-- 6 files changed, 83 insertions(+), 54 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 605b806442..b8511768e6 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -1074,7 +1074,6 @@ end --- @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) @@ -1091,10 +1090,6 @@ function M.button_event(button_rid, event_type, options) } } - 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 } @@ -1107,9 +1102,6 @@ end --- @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) @@ -1124,24 +1116,6 @@ function M.motion_event(motion_rid, is_active, options) } } - 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 } @@ -1153,9 +1127,7 @@ end --- @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) @@ -1167,21 +1139,10 @@ function M.contact_event(contact_rid, state, options) 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 } @@ -1210,8 +1171,11 @@ end --- Create a light SSE event. --- +--- Like the bridge's own updates, it only carries what changed: pass `nil` for `on_state` (or +--- `brightness`) to leave it out, e.g. for a dimming-only update. +--- --- @param light_rid string The light resource ID ---- @param on_state boolean Light on/off state +--- @param on_state boolean|nil Light on/off state, or nil to leave it out --- @param brightness number|nil Brightness level (0-100) --- @param options table|nil Options: --- - color: Color object with xy coordinates @@ -1224,9 +1188,12 @@ function M.light_event(light_rid, on_state, brightness, options) local light_data = { type = "light", id = light_rid, - on = { on = on_state } } + if on_state ~= nil then + light_data.on = { on = on_state } + end + if brightness then light_data.dimming = { brightness = brightness } end @@ -1245,6 +1212,47 @@ function M.light_event(light_rid, on_state, brightness, options) } end +--- Create a device power (battery) SSE event. Battery levels come from a sensor's `device_power` +--- resource, not from its button, motion or contact resource. +--- +--- @param power_rid string The device power resource ID +--- @param owner_device_id string The Hue device ID that owns the resource +--- @param battery_level number Battery level (0-100) +--- @param options table|nil Options: +--- - battery_state: Battery state (default: "normal") +--- - update_type: Event type wrapper (default: "update") +--- @return table SSE event structure +function M.device_power_event(power_rid, owner_device_id, battery_level, options) + options = options or {} + + return { + type = options.update_type or "update", + data = {{ + type = "device_power", + id = power_rid, + owner = { rid = owner_device_id, rtype = "device" }, + power_state = { battery_level = battery_level, battery_state = options.battery_state or "normal" }, + }} + } +end + +--- Combine several SSE events of the same type into one, as the bridge does when several of a +--- device's resources change together (e.g. a sensor's motion and battery level). +--- +--- @param ... table SSE events from the builders above +--- @return table SSE event structure +function M.combined_event(...) + local combined = { type = nil, data = {} } + for _, event in ipairs({ ... }) do + combined.type = combined.type or event.type + assert(event.type == combined.type, "only events of the same type can be combined") + for _, resource in ipairs(event.data) do + table.insert(combined.data, resource) + end + end + return combined +end + --- Create a temperature sensor SSE event. --- --- @param temperature_rid string The temperature sensor resource ID 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 ed3fb72aa6..9efbec3efd 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 @@ -111,7 +111,7 @@ test.register_coroutine_test( modelid = "BSB002", name = BRIDGE_NAME, }, { - { success = { username = "new-bridge-api-key", client_key = "some-client-key" } }, + { success = { username = "new-bridge-api-key", clientkey = "some-client-key" } }, }) mock_devices_api.__expect_create_device({ 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 index 778322e7ce..8a60ac541e 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua @@ -128,6 +128,22 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "an SSE dimming-only update for a light emits only its level", + function() + local sse_conn = connect_sse() + + -- The bridge only sends what changed: a brightness change carries no on/off state. + http.queue_sse_event(sse_conn, { + hue_test_helpers.light_event(LIGHT_RID, nil, 42) + }) + 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() 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 4c2a285752..90d9fd30b1 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 @@ -93,11 +93,13 @@ test.register_coroutine_test( mock_button:generate_test_message("main", capabilities.button.button.pushed({ state_change = true })) ) - -- Send SSE event with both button and battery data using helper + -- The bridge reports the press and the battery level of the button's device_power resource + -- in one event. 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 - }) }) + http.queue_sse_event(sse_conn, { hue_test_helpers.combined_event( + hue_test_helpers.button_event(BUTTON_RID, "short_release"), + hue_test_helpers.device_power_event(POWER_RID, BUTTON_DEVICE_ID, 42) + ) }) test.wait_for_events() end 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 0027efad72..33282dd300 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 @@ -114,9 +114,10 @@ test.register_coroutine_test( 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 - }) }) + http.queue_sse_event(sse, { hue_test_helpers.combined_event( + hue_test_helpers.contact_event(CONTACT_RID, "no_contact"), + hue_test_helpers.device_power_event(POWER_RID, CONTACT_DEVICE_ID, 45) + ) }) test.wait_for_events() end 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 7b55e46131..3be464186c 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 @@ -121,11 +121,13 @@ test.register_coroutine_test( 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 - }) }) + -- A motion sensor's resources can change together; the bridge reports each resource's update + -- in the same event. + http.queue_sse_event(sse, { hue_test_helpers.combined_event( + hue_test_helpers.motion_event(MOTION_RID, true), + hue_test_helpers.device_power_event(POWER_RID, MOTION_DEVICE_ID, 50), + hue_test_helpers.temperature_event(TEMP_RID, 18.0) + ) }) test.wait_for_events() end From 7505b69f2991e6b01e1c3dfb0857df39dc9dea4f Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Mon, 19 Oct 2026 09:57:05 -0500 Subject: [PATCH 15/22] philips-hue: Run every test on the batched capability receive path Current hubs always expose receive_batch, and the driver then routes every capability command through its batch handler. Only the batch command tests turned it on (with a global set before requiring the framework), so the light, plug and error handling command tests ran the single-command handler production no longer uses. Turn batched receives and test coroutine priority on at file level from test.hue_test_helpers, which every test file requires before the driver loads, using the framework's file-level APIs. The discovery tests, which don't use the helpers, do the same themselves. Co-Authored-By: Claude Opus 5.5 --- .../philips-hue/src/test/hue_test_helpers.lua | 8 ++++++-- .../philips-hue/src/test/test_hue_batch_commands.lua | 6 +----- .../src/test/test_hue_bridge_discovery.lua | 11 ++++------- 3 files changed, 11 insertions(+), 14 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 b8511768e6..b4d0495b91 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -3,6 +3,12 @@ local test = require "integration_test" local t_utils = require "integration_test.utils" + +-- File-level test modes for every Hue test file, which all require this module before the driver +-- loads: LAN driver tests run with test coroutine priority, and the driver is tested on the +-- batched capability receive path that current hubs always provide. +test.set_test_coroutine_priority(true) +test.set_capability_batch_receive(true) local connection_scenario = require "integration_test.connection_scenario" local http = require "integration_test.connection_scenario_http" local capabilities = require "st.capabilities" @@ -419,8 +425,6 @@ function HueDeviceBuilder:start() 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) 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 b96d7972ce..7529438833 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 @@ -5,11 +5,7 @@ --- 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 - +-- Batched receives are turned on for every Hue test file by test.hue_test_helpers. local test = require "integration_test" local hue_test_helpers = require "test.hue_test_helpers" local http = require "integration_test.connection_scenario_http" 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 9efbec3efd..072ad7eec2 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 @@ -2,6 +2,10 @@ -- Licensed under the Apache License, Version 2.0 local test = require "integration_test" +-- File-level test modes, as in test.hue_test_helpers: LAN driver tests run with test coroutine +-- priority (discovery rescans every second until stopped), on the batched capability receive path. +test.set_test_coroutine_priority(true) +test.set_capability_batch_receive(true) local connection_scenario = require "integration_test.connection_scenario" local http = require "integration_test.connection_scenario_http" local mock_mdns = test.mock_mdns @@ -23,14 +27,7 @@ test.add_test_env_setup_func(function(driver) 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. Discovery rescans every second until stopped, so let the test act as - -- soon as the driver waits rather than only once mock time has run far ahead. - test.set_test_coroutine_priority(true) -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 From 77ef568b2aba3994379eb3999448909132810a41 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Tue, 20 Oct 2026 11:48:16 -0500 Subject: [PATCH 16/22] philips-hue: Drop test workarounds the stricter connection scenario makes unneeded ConnectionScenario now checks each test's own expectations at the end of the test, makes expectations registered after activate() test-scoped, and prefers a test's expectations (and unmet single-use ones) over the file's reusable defaults. With that, several workarounds in the Hue tests only add noise: - persistent = false on expectations registered in test_init after activate(), which are now test-scoped anyway; - rest:assert_satisfied / scenario:assert_all_satisfied({ test_scoped_only = true }) at the end of tests, which the end-of-test check now does for every test (register_batch_test existed only for that). The mid-test checks that sequence a later assertion stay; - counting requests by pattern-matching get_sent_log(), replaced with count_requests. setup_scenario_test_init's (unused) additional_setup now runs after activate(), so expectations it registers are test-scoped rather than added again by every test. No test was relying on an unmet expectation or undelivered bytes: all of them pass under the stricter checks. Co-Authored-By: Claude Opus 5.5 --- .../philips-hue/src/test/hue_test_helpers.lua | 5 ++-- .../src/test/test_hue_batch_commands.lua | 28 +++++++------------ .../src/test/test_hue_bridge_sse.lua | 4 +-- .../src/test/test_hue_device_connectivity.lua | 3 +- .../src/test/test_hue_driver_restart.lua | 8 ++---- .../src/test/test_hue_error_handling.lua | 1 - .../src/test/test_hue_light_color.lua | 1 - 7 files changed, 19 insertions(+), 31 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 b4d0495b91..fe8e0d0b37 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -742,14 +742,15 @@ end --- --- @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() +--- @param additional_setup function|nil Optional additional setup to run after scenario:activate() +--- (so expectations it registers are test-scoped) function M.setup_scenario_test_init(base_test_init, scenario, additional_setup) local function test_init() base_test_init() + scenario:activate() if additional_setup then additional_setup() end - scenario:activate() end test.set_test_init_function(test_init) 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 7529438833..af59f46396 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 @@ -79,14 +79,6 @@ local function assert_requests(method, path, expected) string.format("expected %d %s %s request(s), got %d", expected, method, path, actual)) end ---- Register a test that also fails if any request it expected was never sent. -local function register_batch_test(name, body) - test.register_coroutine_test(name, function() - body() - rest:assert_satisfied({ test_scoped_only = true }) - end) -end - -- Let the lights finish initializing, then give the bridge its rooms. local function init_and_seed_groups() test.wait_for_events() @@ -95,7 +87,7 @@ local function init_and_seed_groups() "expected 1 room with 2 light records, got " .. #groups .. " rooms") end -register_batch_test( +test.register_coroutine_test( "a batch of one command is sent as a single light request", function() init_and_seed_groups() @@ -110,7 +102,7 @@ register_batch_test( end ) -register_batch_test( +test.register_coroutine_test( "a batch covering every light in a room is sent as one grouped_light request", function() init_and_seed_groups() @@ -128,7 +120,7 @@ register_batch_test( end ) -register_batch_test( +test.register_coroutine_test( "lights outside the matched room are still sent individually", function() init_and_seed_groups() @@ -148,7 +140,7 @@ register_batch_test( end ) -register_batch_test( +test.register_coroutine_test( "matching commands that don't cover a whole room are sent individually", function() init_and_seed_groups() @@ -167,7 +159,7 @@ register_batch_test( end ) -register_batch_test( +test.register_coroutine_test( "commands with different arguments are not grouped", function() init_and_seed_groups() @@ -188,7 +180,7 @@ register_batch_test( end ) -register_batch_test( +test.register_coroutine_test( "commands without a grouped handler are sent individually even for a whole room", function() init_and_seed_groups() @@ -216,7 +208,7 @@ local function set_switch_state(mock_light, state) fixtures.driver_device(mock_light):set_field(Fields.SWITCH_STATE, state, { persist = true }) end -register_batch_test( +test.register_coroutine_test( "a grouped setLevel for a room that is already on only sends the brightness", function() init_and_seed_groups() @@ -237,7 +229,7 @@ register_batch_test( end ) -register_batch_test( +test.register_coroutine_test( "a grouped setLevel turns the room on first when one of its lights is off", function() init_and_seed_groups() @@ -256,7 +248,7 @@ register_batch_test( end ) -register_batch_test( +test.register_coroutine_test( "a grouped setLevel turns the room on first when a light's state is not known yet", function() init_and_seed_groups() @@ -290,7 +282,7 @@ local function capture_logged_errors(fn) return messages end -register_batch_test( +test.register_coroutine_test( "errors the bridge returns for a grouped command are logged", function() init_and_seed_groups() 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 index 8a60ac541e..06546c4e99 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua @@ -50,7 +50,7 @@ local function expect_switch_and_level_emit() end -- The light's refresh: its device, zigbee connectivity and light state. `opts` is passed to each --- expectation (e.g. `times` or `persistent`). +-- expectation (e.g. `times`). local function expect_light_refresh(opts) local function expect(path, data) local spec = { body = { errors = {}, data = data } } @@ -238,7 +238,7 @@ test.register_coroutine_test( }, }) expect_switch_and_level_emit() - expect_light_refresh({ persistent = false }) + expect_light_refresh() test.wait_for_events() 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 e11ae1d711..354254ef4f 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 @@ -91,12 +91,11 @@ http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) test.set_test_init_function(function() scenario:activate() - -- Connectivity poll after the event stream opens. + -- Connectivity poll after the event stream opens (registered after activate(), so test-scoped). 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") 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 d4fea838b9..1ed98691ed 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 @@ -69,7 +69,8 @@ http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) test.set_test_init_function(function() scenario:activate() - -- The connectivity check after the event stream opens reports the lamp connected. + -- The connectivity check after the event stream opens reports the lamp connected (registered + -- after activate(), so it is this test's). http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { status = 200, body = { errors = {}, data = { { @@ -78,8 +79,6 @@ test.set_test_init_function(function() status = "connected", type = "zigbee_connectivity", } } }, - reusable = false, - persistent = false, }) fixtures.test_init() -- adds the devices; registers the lamp's init levelRange emit @@ -111,8 +110,7 @@ test.register_coroutine_test( 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), "") + local light_gets = http.count_requests(rest, "GET", "/clip/v2/resource/light/" .. ids.color_lamp_light) assert(light_gets == 1, string.format("expected 1 light refresh, got %d", light_gets)) 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 7119a4452b..6606087e2a 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 @@ -58,7 +58,6 @@ local function assert_next_command_sent() body = { errors = {}, data = { { rid = LIGHT_RID, rtype = "light" } } }, }) send_command("switch", "off") - scenario:assert_all_satisfied({ test_scoped_only = true }) end test.register_coroutine_test( 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 50ed8ccdb1..5b061c753c 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 @@ -89,7 +89,6 @@ test.set_test_init_function(function() status = 200, body = { errors = {}, data = {} }, reusable = true, - persistent = false, }) test.socket.capability:__set_channel_ordering("relaxed") From 06123e11b32cfa17530e2ddc86a25901e89f243c Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Tue, 20 Oct 2026 10:47:28 -0500 Subject: [PATCH 17/22] philips-hue: Run the LAN tests against a fully initialized bridge Fixtures without an event stream gave the bridge software version "0", so its init logged "Found bridge that does not support CLIP v2 API, ignoring" and added it to the ignored bridges. Tests then marked the bridge initialized by hand (mark_bridge_initialized, or _ADDED/_INIT for non-light children), a state production never reaches: _INIT is only set at the end of do_bridge_network_init, after BRIDGE_API exists. The button lifecycle test passed while the button's refresh crashed on a nil API client. The reason given for this (one mock connection per address) no longer holds, since the connection scenario routes REST and SSE as separate logical connections. Every fixture now uses a CLIP v2 bridge, and the real do_bridge_network_init runs and connects the event stream. The bridge's own added handler sets its fields, and each child's injected refreshes are answered from the bridge. The builder's enable_sse and the non-SSE paths are gone. The light test files answer their lights' init refreshes with setup_light_init_expectations, and the builder can expect the events those refreshes emit (expect_light_init_refreshes). Tests wait for that with wait_for_init before acting. The batch tests now get their groups from the driver's real room and zone scan. Mock time passes until the debounced scan has run, and the scan is answered with realistic CLIP v2 resources: room children are the lights' owning devices, zone children are lights, and each has a grouped_light service. seed_groups, which called grouped_utils.group_add directly, is removed. New tests cover a zone being grouped, and a room that also holds a device the driver doesn't know being left unused. State comes through the protocol where that is straightforward. The batch tests turn a light off with an SSE update instead of setting SWITCH_STATE. The light command tests report a colour over SSE instead of setting COLOR_HUE/COLOR_SATURATION. The unknown-state case still clears SWITCH_STATE, since every init refresh reports it. The driver restart test keeps seeding persisted fields, because that is what a restart hands the driver, and now also seeds the bridge's fields, which its skipped added handler would otherwise set. Helper cleanup: the resource expectation wrappers become one expect_resource. zigbee_rid is a builder option instead of a field every test overrode. The levelRange expectation has one implementation. The HueDeviceBuilder_new double export is dropped. Motion and contact init events are expected in the order the driver emits them, instead of switching the channel to relaxed ordering. Two files whose lights initialize concurrently set relaxed ordering themselves, and explain why. Co-Authored-By: Claude Opus 5.5 --- .../philips-hue/src/test/hue_test_helpers.lua | 1017 ++++++----------- .../src/test/test_hue_batch_commands.lua | 141 ++- .../test/test_hue_bridge_connect_scans.lua | 3 +- .../test/test_hue_bridge_resource_events.lua | 5 +- .../src/test/test_hue_bridge_sse.lua | 3 +- .../src/test/test_hue_button_lifecycle.lua | 13 +- .../src/test/test_hue_button_sse.lua | 10 +- .../test/test_hue_child_device_lifecycle.lua | 30 +- .../src/test/test_hue_contact_sensor_sse.lua | 7 +- .../src/test/test_hue_device_connectivity.lua | 9 +- .../src/test/test_hue_driver_restart.lua | 13 +- .../src/test/test_hue_error_handling.lua | 74 +- .../src/test/test_hue_light_color.lua | 5 +- .../test/test_hue_light_color_temperature.lua | 5 +- .../src/test/test_hue_light_commands.lua | 74 +- .../src/test/test_hue_light_refresh.lua | 78 +- .../src/test/test_hue_motion_sensor_sse.lua | 7 +- .../src/test/test_hue_multibutton_sse.lua | 5 +- .../philips-hue/src/test/test_hue_plug.lua | 20 +- 19 files changed, 683 insertions(+), 836 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 fe8e0d0b37..401bdb726d 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -13,27 +13,24 @@ local connection_scenario = require "integration_test.connection_scenario" local http = require "integration_test.connection_scenario_http" 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. ---- +--- The fixtures pre-populate what an earlier discovery and pairing would have left behind (the +--- bridge's network info and API key in the driver's datastore, each child's resource state in +--- `disco`'s `device_state_disco_cache`), so that the real `added`/`init` lifecycle handlers +--- resolve without discovery (covered separately in test_hue_bridge_discovery.lua). Everything +--- after that runs for real against the mock LAN socket: the bridge's network init connects its +--- event stream, and each child refreshes its state from the bridge. --- --- `HueDeviceBuilder` builds the bridge and child devices; `create_hue_scenario` builds the ---- ConnectionScenario (REST and optionally SSE connections to the bridge) and the +--- ConnectionScenario (the REST and SSE connections to the bridge) and the --- `setup_*_init_expectations` helpers register what each device type requests while it ---- initializes. SSE event builders (`button_event`, `motion_event`, ...) build the payloads to ---- queue with `connection_scenario_http.queue_sse_event`. +--- initializes; `wait_for_init` waits for all of that. SSE event builders (`button_event`, +--- `motion_event`, ...) build the payloads to queue with `connection_scenario_http.queue_sse_event`. + -- `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") @@ -44,35 +41,95 @@ M.BRIDGE_IP = "192.168.1.15" M.BRIDGE_DNI = "AA:BB:CC:DD:EE:FF" M.API_KEY = "test-api-key" +--- 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 ---- `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. +--- Expect the levelRange event `LightLifecycleHandlers.init` emits on every light's first init, +--- whether or not the light was onboarded. A light whose profile has no switchLevel (e.g. a smart +--- plug) drops the event, so nothing is expected for it. HueDeviceBuilder's `test_init` already +--- does this for its lights; only call it directly for a light device built 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. +--- Must be called from `test_init` (before the driver starts), not from a test body. --- --- @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 })) - ) +--- @param profile_filename string|nil the light's profile (default: "white-and-color-ambiance.yml") +function M.expect_light_init_events(mock_light, profile_filename) + if profile_has_capability(profile_filename or "white-and-color-ambiance.yml", capabilities.switchLevel.ID) then + test.socket.capability:__expect_send( + mock_light:generate_test_message("main", capabilities.switchLevel.levelRange({ minimum = 1, maximum = 100 })) + ) + end 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. +--- A Hue room resource, as the bridge reports it: its children are the Hue devices (`rtype` +--- "device") that own its lights, and its `grouped_light` service controls them together. --- ---- @param mock_bridge table -function M.mark_bridge_initialized(mock_bridge) - mock_bridge:set_field(Fields._INIT, true, {}) +--- @param rid string the room's resource ID +--- @param grouped_light_rid string resource ID of the room's grouped_light service +--- @param device_ids string[] Hue device IDs in the room +--- @param name string|nil room name (default "Test Room") +--- @return table +function M.room_resource(rid, grouped_light_rid, device_ids, name) + local children = {} + for _, device_id in ipairs(device_ids) do + table.insert(children, { rid = device_id, rtype = "device" }) + end + return { + id = rid, + type = "room", + metadata = { name = name or "Test Room", archetype = "living_room" }, + children = children, + services = { { rid = grouped_light_rid, rtype = "grouped_light" } }, + } end +--- A Hue zone resource, as the bridge reports it: unlike a room, its children are light resources +--- (`rtype` "light"), and its `grouped_light` service controls them together. +--- +--- @param rid string the zone's resource ID +--- @param grouped_light_rid string resource ID of the zone's grouped_light service +--- @param light_rids string[] light resource IDs in the zone +--- @param name string|nil zone name (default "Test Zone") +--- @return table +function M.zone_resource(rid, grouped_light_rid, light_rids, name) + local children = {} + for _, light_rid in ipairs(light_rids) do + table.insert(children, { rid = light_rid, rtype = "light" }) + end + return { + id = rid, + type = "zone", + metadata = { name = name or "Test Zone", archetype = "lounge" }, + children = children, + services = { { rid = grouped_light_rid, rtype = "grouped_light" } }, + } +end + +--- The events one refresh of a light emits for the given light resource state: its sync mode, +--- switch and level. Colour and colour temperature events aren't derived here; a light with +--- colour state passes its own `refresh_events` to `with_light`. +local function derived_light_refresh_events(state, profile) + assert(state.color == nil and state.color_temperature == nil, + "with_light: pass `refresh_events` for a light with colour state") + local events = {} + if state.mode then + table.insert(events, capabilities["samsungim.hueSyncMode"].mode(state.mode)) + end + if state.on then + table.insert(events, state.on.on and capabilities.switch.switch.on() or capabilities.switch.switch.off()) + end + if state.dimming and profile_has_capability(profile, capabilities.switchLevel.ID) then + table.insert(events, capabilities.switchLevel.level(state.dimming.brightness)) + end + return events +end --- @class HueDeviceBuilder --- Fluent API for building Hue test fixtures with sensible defaults. @@ -83,17 +140,20 @@ HueDeviceBuilder.__index = HueDeviceBuilder --- Create a new HueDeviceBuilder instance. --- --- @return HueDeviceBuilder -function M.HueDeviceBuilder_new() +local function new_builder() 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 + rooms = {}, + zones = {}, } return setmetatable(instance, HueDeviceBuilder) end +M.HueDeviceBuilder = { new = new_builder } + --- Configure the bridge (optional - uses sensible defaults). --- --- @param ip string|nil bridge IP (default: hue_test_helpers.BRIDGE_IP) @@ -117,12 +177,16 @@ end --- - color_temperature: table with mirek and schema (optional) --- - mode: string (default: "normal") --- - hue_device_id: string (default: rid.."-device") +--- - zigbee_rid: the device's zigbee_connectivity resource ID (default: rid.."-zigbee") --- - label: string (default: "Hue Light") +--- - refresh_events: function(first_refresh) returning the capability events one refresh of the +--- light emits (default: derived from mode/on/dimming; required for a light with colour state) --- @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") + profile = profile or "white-and-color-ambiance.yml" -- Build discovery cache state for the light local disco_state = { @@ -143,11 +207,18 @@ function HueDeviceBuilder:with_light(rid, state, profile) }, } + local refresh_events = state.refresh_events + if not refresh_events then + refresh_events = function() return derived_light_refresh_events(disco_state, profile) end + end + table.insert(self.children, { type = "light", rid = rid, - profile = profile or "white-and-color-ambiance.yml", + profile = profile, state = disco_state, + zigbee_rid = state.zigbee_rid or (rid .. "-zigbee"), + refresh_events = refresh_events, }) return self end @@ -161,6 +232,7 @@ end --- - label: device label (default "Hue Button") --- - device_id: device ID (default: rid .. "-device") --- - power_rid: power resource ID (default: rid .. "-power") +--- - zigbee_rid: zigbee_connectivity resource ID (default: rid .. "-zigbee") --- - 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 @@ -213,8 +285,8 @@ function HueDeviceBuilder:with_button(rid, config, profile) 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 + battery = config.battery or 85, + zigbee_rid = config.zigbee_rid or (rid .. "-zigbee"), }) return self end @@ -230,6 +302,7 @@ end --- - label: device label (default "Hue Motion Sensor") --- - device_id: device ID (default: rid .. "-device") --- - power_rid: power resource ID (default: rid .. "-power") +--- - zigbee_rid: zigbee_connectivity resource ID (default: rid .. "-zigbee") --- - temperature_rid: temperature resource ID (default: rid .. "-temp") --- - light_level_rid: light level resource ID (default: rid .. "-light") --- @return HueDeviceBuilder self for chaining @@ -272,7 +345,7 @@ function HueDeviceBuilder:with_motion(rid, config) 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 + zigbee_rid = config.zigbee_rid or (rid .. "-zigbee"), }) return self end @@ -287,6 +360,7 @@ end --- - label: device label (default "Hue Contact Sensor") --- - device_id: device ID (default: rid .. "-device") --- - power_rid: power resource ID (default: rid .. "-power") +--- - zigbee_rid: zigbee_connectivity resource ID (default: rid .. "-zigbee") --- - tamper_rid: tamper resource ID (default: rid .. "-tamper") --- @return HueDeviceBuilder self for chaining function HueDeviceBuilder:with_contact(rid, config) @@ -321,16 +395,13 @@ function HueDeviceBuilder:with_contact(rid, config) 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 + zigbee_rid = config.zigbee_rid or (rid .. "-zigbee"), }) return self end ---- Add a Hue room containing some of this fixture's lights. ---- ---- The driver only scans the bridge's rooms after its event stream connects, so for a fixture ---- without SSE 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`. +--- Add a Hue room containing some of this fixture's lights. The bridge reports it (as +--- `fixtures.rooms`, for `create_hue_scenario`'s `rooms` option) to the driver's room scan. --- --- @param rid string Hue resource ID for the room --- @param grouped_light_rid string resource ID of the room's grouped_light service @@ -338,42 +409,50 @@ end --- @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", - }) + table.insert(self.rooms, { rid = rid, grouped_light_rid = grouped_light_rid, light_rids = light_rids, name = name }) return self end ---- Enable SSE support for this fixture. +--- Add a Hue zone containing some of this fixture's lights. The bridge reports it (as +--- `fixtures.zones`, for `create_hue_scenario`'s `zones` option) to the driver's zone scan. --- +--- @param rid string Hue resource ID for the zone +--- @param grouped_light_rid string resource ID of the zone's grouped_light service +--- @param light_rids string[] resource IDs of lights (added with `with_light`) in the zone +--- @param name string|nil zone name (default "Test Zone") --- @return HueDeviceBuilder self for chaining -function HueDeviceBuilder:enable_sse() - self.sse_enabled = true +function HueDeviceBuilder:with_zone(rid, grouped_light_rid, light_rids, name) + table.insert(self.zones, { rid = rid, grouped_light_rid = grouped_light_rid, light_rids = light_rids, name = name }) 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 +--- Have the fixture's `test_init` expect the events of the refreshes each light's `added` and +--- `init` handlers inject (see `with_light`'s `refresh_events`), for a test that answers them with +--- `setup_light_init_expectations`. Without it the test registers those events itself. +--- +--- @return HueDeviceBuilder self for chaining +function HueDeviceBuilder:expect_light_init_refreshes() + self.light_init_refreshes = true + return self end --- Build the fixture and return a structured fixtures object. ---- This creates all mock devices and returns them with their configurations. +--- +--- The bridge is one the driver has already found and paired with: its network info (with a +--- software version that supports the CLIP v2 API) and API key are in the driver's datastore, so +--- its `init` runs the real network init right away, which connects the bridge's event stream. +--- Each child's resource state is in the discovery cache, so its `added` and `init` resolve +--- without discovery; they then refresh it from the bridge as in production. The test's +--- ConnectionScenario must answer all of that (`create_hue_scenario`, `setup_sse_expectations` +--- and the `setup_*_init_expectations` / `expect_light_init_refreshes` helpers). --- --- @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) +--- - rooms, zones: the bridge's room and zone resources (see `with_room`, `with_zone`) +--- - driver_device: function(mock_device) returning the driver's own device object function HueDeviceBuilder:start() local mock_bridge = test.mock_device.build_test_lan_device({ label = "Hue Bridge", @@ -396,173 +475,88 @@ function HueDeviceBuilder:start() test.add_test_env_setup_func(function(driver) driver_under_test = driver driver.datastore.bridge_netinfo = driver.datastore.bridge_netinfo or {} - if self.sse_enabled then - driver.datastore.bridge_netinfo[self.bridge_dni] = { - ip = self.bridge_ip, - swversion = tostring(HueApi.MIN_CLIP_V2_SWVERSION), - modelid = "BSB002" - } - driver.joined_bridges[self.bridge_dni] = true - else - driver.datastore.bridge_netinfo[self.bridge_dni] = { - ip = self.bridge_ip, - swversion = "0", - modelid = "BSB002" - } - end + driver.datastore.bridge_netinfo[self.bridge_dni] = { + ip = self.bridge_ip, + swversion = tostring(HueApi.MIN_CLIP_V2_SWVERSION), + modelid = "BSB002" + } + -- The bridge was found on the network during this run of the driver (as when it has just + -- been onboarded), so its init doesn't have to wait for its network info. + driver.joined_bridges[self.bridge_dni] = true 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 + -- Discovery is still running (as when the devices have just been onboarded), so the event + -- stream connecting doesn't also start a scan of every device on the bridge. + disco.discovery_active = true -- Populate disco cache with child device states - for i, child_config in ipairs(self.children) do + for _, 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 expect_events(mock_device, events) + for _, event in ipairs(events) do + test.socket.capability:__expect_send(mock_device:generate_test_message("main", event)) + end + end + local function test_init() test.mock_device.add_test_device(mock_bridge) for _, mock_child in ipairs(mock_children) do test.mock_device.add_test_device(mock_child) end - mock_bridge:set_field(Fields.DEVICE_TYPE, "bridge", {}) - mock_bridge:set_field(Fields.BRIDGE_ID, self.bridge_dni, {}) - mock_bridge:set_field(Fields.IPV4, self.bridge_ip, {}) - mock_bridge:set_field(HueApi.APPLICATION_KEY_HEADER, self.api_key, {}) - - -- Check if we have any non-light children (buttons, sensors, etc.) - -- These need the bridge marked as _ADDED to avoid being treated as stray devices - local has_non_light_children = false - for _, child_config in ipairs(self.children) do - if child_config.type ~= "light" then - has_non_light_children = true - break - end - end - - if has_non_light_children then - mock_bridge:set_field(Fields._ADDED, true, { persist = true }) - -- Don't mark _INIT yet if SSE is enabled - let do_bridge_network_init run to set up SSE - if not self.sse_enabled then - mock_bridge:set_field(Fields._INIT, true, { persist = true }) - end - end - - -- Register init expectations for all children - -- Generate init_expectations based on device type and SSE status + -- The events each child emits while it initializes. 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 + -- supportedButtonValues for each button's component, then the refresh's battery level. 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) + capabilities.button.supportedButtonValues({ "pushed", "held" }, { visibility = { displayed = false } }) ) ) end + expect_events(mock_child, { capabilities.battery.battery(child_config.battery) }) 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 + -- The refresh's state, in the order the driver emits it. Illuminance from light_level: lux = round(10^((light_level - 1) / 10000)). + local lux = math.floor(10 ^ ((child_config.light_level - 1) / 10000) + 0.5) + expect_events(mock_child, { + capabilities.battery.battery(child_config.battery), + capabilities.temperatureMeasurement.temperature({ value = child_config.temperature, unit = "C" }), + capabilities.illuminanceMeasurement.illuminance(lux), + capabilities.motionSensor.motion[child_config.motion and "active" or "inactive"](), + }) 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 + -- The refresh's state, in the order the driver emits it. + expect_events(mock_child, { + capabilities.battery.battery(child_config.battery), + capabilities.tamperAlert.tamper[child_config.tamper == "tampered" and "detected" or "clear"](), + capabilities.contactSensor.contact[child_config.contact_state == "no_contact" and "open" or "closed"](), + }) elseif child_config.type == "light" then - -- Light init emits levelRange; for a profile without switchLevel (e.g. a smart plug) the - -- device drops the event, so nothing is sent. - 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 }) - ) - ) + -- Unless the test registers them itself, the refreshes the light's `added` and `init` + -- inject, around init's levelRange. + if self.light_init_refreshes then + expect_events(mock_child, child_config.refresh_events(true)) + end + M.expect_light_init_events(mock_child, child_config.profile) + if self.light_init_refreshes then + expect_events(mock_child, child_config.refresh_events(false)) end end end @@ -570,7 +564,7 @@ function HueDeviceBuilder:start() -- Build configuration objects for each device type local configs = {} - for i, child_config in ipairs(self.children) do + for _, child_config in ipairs(self.children) do local device_key = child_config.type if not configs[device_key] then configs[device_key] = {} @@ -578,6 +572,10 @@ function HueDeviceBuilder:start() -- Build config based on device type if child_config.type == "button" then + local button_rids = {} + for j = 1, child_config.num_buttons do + table.insert(button_rids, child_config.state["button" .. j .. "_id"]) + end table.insert(configs[device_key], { rid = child_config.rid, device_id = child_config.state.hue_device_id, @@ -585,20 +583,16 @@ function HueDeviceBuilder:start() 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 + zigbee_rid = child_config.zigbee_rid, + button_rids = button_rids, }) - -- 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"), + zigbee_rid = child_config.zigbee_rid, on = child_config.state.on, dimming = child_config.state.dimming, color = child_config.state.color, @@ -616,7 +610,7 @@ function HueDeviceBuilder:start() 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"), + zigbee_rid = child_config.zigbee_rid, temperature_rid = child_config.state.temperature_id, light_level_rid = child_config.state.light_level_id }) @@ -630,7 +624,7 @@ function HueDeviceBuilder:start() 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"), + zigbee_rid = child_config.zigbee_rid, tamper_rid = child_config.state.tamper_id }) end @@ -641,36 +635,20 @@ function HueDeviceBuilder:start() hue_device_id_by_rid[child_config.rid] = child_config.state.hue_device_id end - -- The rooms declared with `with_room`, as the bridge's room resources. local room_resources = {} - for _, room in ipairs(self.rooms or {}) do - local children = {} + for _, room in ipairs(self.rooms) do + local device_ids = {} for _, light_rid in ipairs(room.light_rids) do - table.insert(children, { rid = hue_device_id_by_rid[light_rid], rtype = "device" }) + table.insert(device_ids, (assert(hue_device_id_by_rid[light_rid], "with_room: unknown light " .. light_rid))) end - table.insert(room_resources, { - id = room.rid, - type = "room", - metadata = { name = room.name }, - children = children, - services = { { rid = room.grouped_light_rid, rtype = "grouped_light" } }, - }) + table.insert(room_resources, M.room_resource(room.rid, room.grouped_light_rid, device_ids, room.name)) 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(room_resources) do - grouped_utils.group_add(driver_under_test, bridge_device, room) - end - return bridge_device:get_field(Fields.GROUPS) + local zone_resources = {} + for _, zone in ipairs(self.zones) do + table.insert(zone_resources, M.zone_resource(zone.rid, zone.grouped_light_rid, zone.light_rids, zone.name)) end - --- The driver's own device object for a mock device, e.g. to inspect or seed device fields. + --- The driver's own device object for a mock device, e.g. to inspect its 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) @@ -683,27 +661,22 @@ function HueDeviceBuilder:start() test_init = test_init, configs = configs, rooms = room_resources, - seed_groups = seed_groups, + zones = zone_resources, driver_device = driver_device, } end --- Export HueDeviceBuilder via a constructor function -M.HueDeviceBuilder = { - new = M.HueDeviceBuilder_new -} +--- ConnectionScenario helpers ---- ConnectionScenario 2.0 Test Helpers ---- These helpers reduce boilerplate when using the new connection_scenario framework - ---- Create a ConnectionScenario configured for Hue bridge testing. +--- Create a ConnectionScenario for the fixture's bridge: a REST connection for its CLIP v2 +--- resources and the SSE connection for its event stream. --- --- @param options table|nil Configuration options: --- - host: Bridge IP (default: hue_test_helpers.BRIDGE_IP) --- - port: Bridge port (default: 443) --- - rooms: Hue room resources the bridge reports to the driver's group scans (default: none), --- e.g. `fixtures.rooms` from a HueDeviceBuilder with `with_room` ---- - sse: Include the SSE event stream connection (default: false) +--- - zones: Hue zone resources, likewise (e.g. `fixtures.zones`) --- @return table scenario The ConnectionScenario instance --- @return table connections `{ rest = ..., sse = ... }` function M.create_hue_scenario(options) @@ -726,14 +699,12 @@ function M.create_hue_scenario(options) reusable = true, }) http.expect_request(connections.rest, "GET", "/clip/v2/resource/zone", { - body = { errors = {}, data = {} }, + body = { errors = {}, data = options.zones or {} }, reusable = true, }) - if options.sse then - connections.sse = scenario:connection("sse", { - matcher = http.matcher("GET", "^/eventstream/clip/v2") - }) - end + connections.sse = scenario:connection("sse", { + matcher = http.matcher("GET", "^/eventstream/clip/v2") + }) return scenario, connections end @@ -755,295 +726,96 @@ function M.setup_scenario_test_init(base_test_init, scenario, additional_setup) 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 {} - 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 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}). +--- Expect a GET of one of the bridge's CLIP v2 resources (GET /clip/v2/resource/{rtype}/{rid}), +--- answered with that resource. --- ---- @param connection table The connection handle ---- @param power_rid string The device power resource ID ---- @param battery_level number Battery level (0-100) +--- @param connection table The REST connection handle +--- @param rtype string The resource type, e.g. "device", "zigbee_connectivity", "light" +--- @param rid string The resource ID +--- @param data table|nil The resource's fields besides `type` and `id`, e.g. `{ on = { on = true } }` --- @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) +--- - times: How many requests a single-use expectation answers (default: 1) +function M.expect_resource(connection, rtype, rid, data, options) options = options or {} - return http.expect_request(connection, "GET", "/clip/v2/resource/device_power/" .. power_rid, { + local resource = { type = rtype, id = rid } + for k, v in pairs(data or {}) do resource[k] = v end + return http.expect_request(connection, "GET", "/clip/v2/resource/" .. rtype .. "/" .. rid, { status = options.status or 200, - body = { - errors = {}, - data = {{ - type = "device_power", - id = power_rid, - power_state = { battery_level = battery_level } - }} - }, - reusable = options.reusable + body = { errors = {}, data = { resource } }, + reusable = options.reusable, + times = options.times, }) end ---- Expect a Hue button resource request (GET /clip/v2/resource/button/{id}). +--- Expect a GET of a Hue device (GET /clip/v2/resource/device/{id}), as a refresh makes to find the +--- device's zigbee_connectivity service. --- ---- @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 {} - 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 {} - 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 {} - 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 {} - 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) +--- @param connection table The REST connection handle +--- @param device_id string The Hue device ID +--- @param services table Array of the device's services, e.g. `{ { rtype = "light", rid = ... } }` +--- @param options table|nil `expect_resource` options, plus `name` and `product_data` +function M.expect_device_info(connection, device_id, services, options) options = options or {} - 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 - }) + return M.expect_resource(connection, "device", device_id, { + metadata = { name = options.name or "Device" }, + product_data = options.product_data, + services = services, + }, options) end ---- Expect a Hue tamper sensor resource request (GET /clip/v2/resource/tamper/{id}). +--- Expect a GET of a Hue device's zigbee_connectivity resource, reporting it connected. --- ---- @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) +--- @param connection table The REST connection handle +--- @param zigbee_rid string The zigbee_connectivity resource ID +--- @param owner string The ID of the Hue device that owns it +--- @param options table|nil `expect_resource` options, plus `status_value` (default "connected") +function M.expect_zigbee_connectivity(connection, zigbee_rid, owner, options) options = options or {} - 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 - }) + return M.expect_resource(connection, "zigbee_connectivity", zigbee_rid, { + owner = { rid = owner, rtype = "device" }, + status = options.status_value or "connected", + }, options) end ---- Expect a Hue light resource request (GET /clip/v2/resource/light/{id}). +--- A light resource response (`{ errors = {}, data = { light } }`) for a light added with +--- `with_light`, from its `fixtures.configs.light` entry. --- ---- @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 light_data = { +--- @param light_config table +--- @return table +function M.light_resource(light_config) + return { errors = {}, 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 - }) + id = light_config.rid, + owner = { rid = light_config.device_id, rtype = "device" }, + metadata = { name = light_config.label }, + on = light_config.on, + dimming = light_config.dimming, + color = light_config.color, + color_temperature = light_config.color_temperature, + mode = light_config.mode, + } } } end ---- Assert that the bridge's event stream connected: the bridge is marked online once it does, the ---- handshake went out on the SSE connection, and every request expected while the devices ---- initialize (the scenario's single-use expectations) was made. +--- Wait until the fixture's devices have initialized: the bridge's event stream has connected +--- (which marks the bridge online) and every request expected so far (the scenario's single-use +--- expectations, e.g. those registered by the `setup_*_init_expectations` helpers) has been made. --- --- @param scenario table The ConnectionScenario instance --- @param mock_bridge table -function M.assert_event_stream_connected(scenario, mock_bridge) - test.wait_until(function() return test.mock_devices_api.__is_device_online(mock_bridge.id) == true end, - { message = "the bridge being marked online when its event stream connects" }) +function M.wait_for_init(scenario, mock_bridge) + local function all_requests_made() + for _, label in ipairs({ "rest", "sse" }) do + if #scenario:get_connection(label):unsatisfied_expectations() > 0 then return false end + end + return true + end + test.wait_until(function() + return test.mock_devices_api.__is_device_online(mock_bridge.id) == true and all_requests_made() + end, { message = "the bridge's event stream connecting and the devices' init requests being made" }) scenario:get_connection("sse"):assert_sent("^GET /eventstream/clip/v2 HTTP/1.1\r\n") - scenario:assert_all_satisfied() + test.wait_for_events() end --- Setup SSE connection expectations: the handshake and the connectivity scan the driver makes @@ -1317,188 +1089,127 @@ function M.escape_uuid(uuid) end --- Device-Type-Specific Init Expectation Setup Helpers ---- These high-level helpers configure all standard init-time expectations for a device type. +--- Each registers the requests a device of that type makes while it initializes, answered with the +--- state its `fixtures.configs` entry describes. Register them at file level. ---- 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) +--- A device's device_power resource fields. +local function device_power(config) + return { + owner = { rid = config.device_id, rtype = "device" }, + power_state = { battery_level = config.battery, battery_state = "normal" }, + } +end + +--- Setup the init-time requests for a light: its two refreshes (injected by its `added` and +--- `init` handlers), each a GET of its device, its zigbee_connectivity and its light resource. +--- Pair with the builder's `expect_light_init_refreshes` for the events they emit. --- --- @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 light_config table The light's `fixtures.configs.light` entry --- @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) +--- - light_resource: the light resource response (default: `light_resource(light_config)`) +function M.setup_light_init_expectations(rest_connection, light_config, options) options = options or {} - -- Default services if not provided - local services = options.services or { + -- Reusable: a later refresh makes these requests too. + M.expect_device_info(rest_connection, light_config.device_id, { + { rtype = "zigbee_connectivity", rid = light_config.zigbee_rid }, + { rtype = "light", rid = light_config.rid }, + }, { name = light_config.label, reusable = true }) + M.expect_zigbee_connectivity(rest_connection, light_config.zigbee_rid, light_config.device_id, { reusable = true }) + http.expect_request(rest_connection, "GET", "/clip/v2/resource/light/" .. light_config.rid, { + body = options.light_resource or M.light_resource(light_config), + times = 2, + }) +end + +--- Setup the init-time requests for a button device: its device, zigbee connectivity, button +--- resources and device power, plus the event stream's (if `sse_connection` is given). +--- +--- @param rest_connection table The REST connection handle +--- @param sse_connection table|nil The SSE connection handle, to also expect the event stream +--- @param button_config table The button's `fixtures.configs.button` entry +function M.setup_button_init_expectations(rest_connection, sse_connection, button_config) + local services = { { rtype = "zigbee_connectivity", rid = button_config.zigbee_rid }, - { rtype = "button", rid = button_config.button_rids[1] }, - { rtype = "device_power", rid = button_config.power_rid } + { rtype = "device_power", rid = button_config.power_rid }, } - - -- 1. Device info + for _, button_rid in ipairs(button_config.button_rids) do + table.insert(services, { rtype = "button", rid = button_rid }) + end 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 + reusable = true, }) - - -- 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) + M.expect_zigbee_connectivity(rest_connection, button_config.zigbee_rid, button_config.device_id) + for i, button_rid in ipairs(button_config.button_rids) do + M.expect_resource(rest_connection, "button", button_rid, { + owner = { rid = button_config.device_id, rtype = "device" }, + metadata = { control_id = i }, + button = { + button_report = { event = "initial_press", updated = "2024-01-01T00:00:00Z" }, + event_values = { "short_release", "long_press", "long_release" }, + }, + }) end - - -- 4. Device power - M.expect_device_power(rest_connection, button_config.power_rid, button_config.battery) - - -- 5. SSE handshake (if SSE enabled) + M.expect_resource(rest_connection, "device_power", button_config.power_rid, device_power(button_config)) if sse_connection then M.setup_sse_expectations(sse_connection, rest_connection) end - 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) +--- Setup the init-time requests for a motion sensor: its device, zigbee connectivity, motion, +--- temperature, light level and device power resources, plus the event stream's (if +--- `sse_connection` is given). --- --- @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 {} - -- Default services if not provided - local services = options.services or { +--- @param sse_connection table|nil The SSE connection handle, to also expect the event stream +--- @param motion_config table The sensor's `fixtures.configs.motion` entry +function M.setup_motion_init_expectations(rest_connection, sse_connection, motion_config) + local owner = { rid = motion_config.device_id, rtype = "device" } + M.expect_device_info(rest_connection, motion_config.device_id, { { 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) + { rtype = "device_power", rid = motion_config.power_rid }, + }, { name = motion_config.label, reusable = true }) + M.expect_zigbee_connectivity(rest_connection, motion_config.zigbee_rid, motion_config.device_id) + M.expect_resource(rest_connection, "motion", motion_config.rid, + { owner = owner, enabled = true, motion = { motion = motion_config.motion, motion_valid = true } }) + M.expect_resource(rest_connection, "temperature", motion_config.temperature_rid, + { owner = owner, enabled = true, temperature = { temperature = motion_config.temperature, temperature_valid = true } }) + M.expect_resource(rest_connection, "light_level", motion_config.light_level_rid, + { owner = owner, enabled = true, light = { light_level = motion_config.light_level, light_level_valid = true } }) + M.expect_resource(rest_connection, "device_power", motion_config.power_rid, device_power(motion_config)) if sse_connection then M.setup_sse_expectations(sse_connection, rest_connection) end - 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) +--- Setup the init-time requests for a contact sensor: its device, zigbee connectivity, contact, +--- tamper and device power resources, plus the event stream's (if `sse_connection` is given). --- --- @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 {} - -- Default services if not provided - local services = options.services or { +--- @param sse_connection table|nil The SSE connection handle, to also expect the event stream +--- @param contact_config table The sensor's `fixtures.configs.contact` entry +function M.setup_contact_init_expectations(rest_connection, sse_connection, contact_config) + local owner = { rid = contact_config.device_id, rtype = "device" } + M.expect_device_info(rest_connection, contact_config.device_id, { { 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) + { rtype = "device_power", rid = contact_config.power_rid }, + }, { name = contact_config.label, reusable = true }) + M.expect_zigbee_connectivity(rest_connection, contact_config.zigbee_rid, contact_config.device_id) + M.expect_resource(rest_connection, "contact", contact_config.rid, + { owner = owner, enabled = true, contact_report = { state = contact_config.contact_state } }) + M.expect_resource(rest_connection, "tamper", contact_config.tamper_rid, + { owner = owner, tamper_reports = { { state = contact_config.tamper } } }) + M.expect_resource(rest_connection, "device_power", contact_config.power_rid, device_power(contact_config)) if sse_connection then M.setup_sse_expectations(sse_connection, rest_connection) end - end return M diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_batch_commands.lua index af59f46396..424c1f723b 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 @@ -2,46 +2,71 @@ -- Licensed under the Apache License, Version 2.0 --- 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. +--- bridge as a single grouped_light request when they exactly cover a Hue room or zone, and as +--- per-light requests otherwise. The rooms and zones come from the driver's own scan of the +--- bridge, which it runs (debounced) once the lights are added and the event stream connects. -- Batched receives are turned on for every Hue test file by test.hue_test_helpers. local test = require "integration_test" local hue_test_helpers = require "test.hue_test_helpers" local http = require "integration_test.connection_scenario_http" +local capabilities = require "st.capabilities" local Fields = require "fields" local LIGHT_1_RID = "11111111-1111-1111-1111-111111111111" local LIGHT_2_RID = "22222222-2222-2222-2222-222222222222" local LIGHT_3_RID = "33333333-3333-3333-3333-333333333333" +local LIGHT_4_RID = "44444444-4444-4444-4444-444444444444" +local LIGHT_5_RID = "55555555-5555-5555-5555-555555555555" local ROOM_RID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" local GROUPED_LIGHT_RID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" +local ZONE_RID = "cccccccc-cccc-cccc-cccc-cccccccccccc" +local ZONE_GROUPED_LIGHT_RID = "dddddddd-dddd-dddd-dddd-dddddddddddd" +local OTHER_ROOM_RID = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" +local OTHER_ROOM_GROUPED_LIGHT_RID = "ffffffff-ffff-ffff-ffff-ffffffffffff" +-- A Hue device on the bridge (e.g. one added after onboarding) that the driver has no record of. +local UNKNOWN_DEVICE_ID = "99999999-9999-9999-9999-999999999999" 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", - } + return { on = { on = true }, dimming = { brightness = 100 }, mode = "normal" } end --- Lights 1 and 2 make up the room; light 3 is outside it. +-- Lights 1 and 2 make up the room and lights 4 and 5 the zone. Light 3 shares a second room with a +-- device the driver doesn't know about, so that room can't be used to control 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_light(LIGHT_4_RID, light_state(), "white-and-color-ambiance.yml") + :with_light(LIGHT_5_RID, light_state(), "white-and-color-ambiance.yml") :with_room(ROOM_RID, GROUPED_LIGHT_RID, { LIGHT_1_RID, LIGHT_2_RID }, "Living Room") + :with_zone(ZONE_RID, ZONE_GROUPED_LIGHT_RID, { LIGHT_4_RID, LIGHT_5_RID }, "Reading Corner") + :expect_light_init_refreshes() :start() +local mock_bridge = fixtures.bridge local light_1, light_2, light_3 = fixtures.devices[1], fixtures.devices[2], fixtures.devices[3] +local light_4, light_5 = fixtures.devices[4], fixtures.devices[5] -local scenario, conns = hue_test_helpers.create_hue_scenario({ rooms = fixtures.rooms }) -local rest = conns.rest +local rooms = fixtures.rooms +table.insert(rooms, hue_test_helpers.room_resource(OTHER_ROOM_RID, OTHER_ROOM_GROUPED_LIGHT_RID, + { fixtures.configs.light[3].device_id, UNKNOWN_DEVICE_ID }, "Hallway")) -hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) +local scenario, conns = hue_test_helpers.create_hue_scenario({ rooms = rooms, zones = fixtures.zones }) +local rest, sse = conns.rest, conns.sse + +for _, light_config in ipairs(fixtures.configs.light) do + hue_test_helpers.setup_light_init_expectations(rest, light_config) +end +hue_test_helpers.setup_sse_expectations(sse, rest) + +hue_test_helpers.setup_scenario_test_init(function() + fixtures.test_init() + -- The lights initialize concurrently, each waiting on its own refresh's requests, so their init + -- events interleave; each light's own events are still in order. + test.socket.capability:__set_channel_ordering("relaxed") +end, scenario) local function switch_cmd(command) return { capability = "switch", component = "main", command = command, args = {} } @@ -79,18 +104,23 @@ local function assert_requests(method, path, 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") +-- Let the lights finish initializing, then let mock time pass until the driver's (debounced) +-- room and zone scan has run and found the bridge's groups. +local function init_and_scan_groups() + hue_test_helpers.wait_for_init(scenario, mock_bridge) + local bridge = fixtures.driver_device(mock_bridge) + test.wait_until(function() + return http.count_requests(rest, "GET", "/clip/v2/resource/zone") >= 1 and bridge:get_field(Fields.GROUPS) ~= nil + end, { max_turns = 500, message = "the room and zone scan" }) + assert(http.count_requests(rest, "GET", "/clip/v2/resource/room") >= 1, "expected the room scan") + -- Back to strict ordering for the events of the test itself. + test.socket.capability:__set_channel_ordering("strict") end test.register_coroutine_test( "a batch of one command is sent as a single light request", function() - init_and_seed_groups() + init_and_scan_groups() expect_light_put(LIGHT_1_RID) test.socket.capability:__queue_receive_batch({ { light_1.id, switch_cmd("on") } }) @@ -105,7 +135,7 @@ test.register_coroutine_test( test.register_coroutine_test( "a batch covering every light in a room is sent as one grouped_light request", function() - init_and_seed_groups() + init_and_scan_groups() expect_grouped_light_put(GROUPED_LIGHT_RID) test.socket.capability:__queue_receive_batch({ @@ -123,7 +153,7 @@ test.register_coroutine_test( test.register_coroutine_test( "lights outside the matched room are still sent individually", function() - init_and_seed_groups() + init_and_scan_groups() expect_grouped_light_put(GROUPED_LIGHT_RID) expect_light_put(LIGHT_3_RID) @@ -143,7 +173,7 @@ test.register_coroutine_test( test.register_coroutine_test( "matching commands that don't cover a whole room are sent individually", function() - init_and_seed_groups() + init_and_scan_groups() expect_light_put(LIGHT_1_RID) expect_light_put(LIGHT_3_RID) @@ -162,7 +192,7 @@ test.register_coroutine_test( test.register_coroutine_test( "commands with different arguments are not grouped", function() - init_and_seed_groups() + init_and_scan_groups() expect_light_put(LIGHT_1_RID) expect_light_put(LIGHT_2_RID) @@ -183,7 +213,7 @@ test.register_coroutine_test( test.register_coroutine_test( "commands without a grouped handler are sent individually even for a whole room", function() - init_and_seed_groups() + init_and_scan_groups() expect_light_put(LIGHT_1_RID) expect_light_put(LIGHT_2_RID) @@ -204,16 +234,11 @@ 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") + -- The lights' init refreshes reported them all on. + init_and_scan_groups() expect_grouped_light_put(GROUPED_LIGHT_RID, { dimming = { brightness = 23 } }) test.socket.capability:__queue_receive_batch({ @@ -232,9 +257,12 @@ test.register_coroutine_test( 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") + init_and_scan_groups() + -- The bridge reports light 2 turned off. + http.queue_sse_event(sse, { hue_test_helpers.light_event(LIGHT_2_RID, false) }) + test.socket.capability:__expect_send(light_2:generate_test_message("main", capabilities.switch.switch.off())) + test.wait_for_events() + expect_grouped_light_put(GROUPED_LIGHT_RID, { on = { on = true } }) expect_grouped_light_put(GROUPED_LIGHT_RID, { dimming = { brightness = 23 } }) @@ -251,9 +279,11 @@ test.register_coroutine_test( test.register_coroutine_test( "a grouped setLevel turns the room on first when a light's state is not known yet", function() - init_and_seed_groups() - set_switch_state(light_1, "on") - set_switch_state(light_2, nil) + init_and_scan_groups() + -- A light's switch state is unknown when the bridge has never reported it, e.g. when its + -- refreshes failed. Every light's init refresh here succeeds, so the driver's record of light + -- 2's state is cleared directly instead. + fixtures.driver_device(light_2):set_field(Fields.SWITCH_STATE, nil, { persist = true }) expect_grouped_light_put(GROUPED_LIGHT_RID, { on = { on = true } }) expect_grouped_light_put(GROUPED_LIGHT_RID, { dimming = { brightness = 23 } }) @@ -267,6 +297,39 @@ test.register_coroutine_test( end ) +test.register_coroutine_test( + "a batch covering every light in a zone is sent as one grouped_light request", + function() + init_and_scan_groups() + expect_grouped_light_put(ZONE_GROUPED_LIGHT_RID, { on = { on = false } }) + + test.socket.capability:__queue_receive_batch({ + { light_4.id, switch_cmd("off") }, + { light_5.id, switch_cmd("off") }, + }) + test.wait_for_events() + + assert_requests("PUT", GROUPED_LIGHT_PATH .. ZONE_GROUPED_LIGHT_RID, 1) + assert_requests("PUT", LIGHT_PATH, 0) + end +) + +test.register_coroutine_test( + "a room containing a device the driver doesn't know is not used to group commands", + function() + init_and_scan_groups() + -- Light 3 is the only light of its room the driver has, but the room also holds an unknown + -- device, so a grouped request for it could change that device too. + expect_light_put(LIGHT_3_RID, { on = { on = true } }) + + test.socket.capability:__queue_receive_batch({ { light_3.id, switch_cmd("on") } }) + test.wait_for_events() + + assert_requests("PUT", LIGHT_PATH .. LIGHT_3_RID, 1) + assert_requests("PUT", GROUPED_LIGHT_PATH, 0) + end +) + --- Run `fn`, returning the messages logged with `log.error_with` while it ran. local function capture_logged_errors(fn) local log = require "log" @@ -285,7 +348,7 @@ end test.register_coroutine_test( "errors the bridge returns for a grouped command are logged", function() - init_and_seed_groups() + init_and_scan_groups() http.expect_request_for_test(rest, "PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, { status = 503, body = { errors = { { description = "bridge is busy" } }, data = {} }, diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua index a718321446..3f19ef89bd 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua @@ -13,10 +13,9 @@ local ROOM_PATH = "/clip/v2/resource/room" local fixtures = hue_test_helpers.HueDeviceBuilder.new() :with_bridge() - :enable_sse() :start() -local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local scenario, conns = hue_test_helpers.create_hue_scenario() local rest, sse = conns.rest, conns.sse http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) 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 fcdd927fc2..db99ea09ef 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 @@ -23,18 +23,17 @@ 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 scenario, conns = hue_test_helpers.create_hue_scenario() 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 }) +hue_test_helpers.expect_zigbee_connectivity(rest, ids.plug_zigbee, 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, }) 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 index 06546c4e99..82c4b5fab3 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua @@ -30,13 +30,12 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() 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 scenario, conns = hue_test_helpers.create_hue_scenario() local rest, sse = conns.rest, conns.sse -- Helper to expect switch and level events 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 e7effc410f..a9f9fd841d 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,8 +1,8 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 ---- A button whose state is in the discovery cache is added and initialized without any REST ---- requests (no address is registered, so any connection attempt would fail the test). +--- A button whose state is in the discovery cache is added and initialized from it, then +--- refreshed from the bridge once the bridge has initialized. local test = require "integration_test" local hue_test_helpers = require "test.hue_test_helpers" @@ -13,7 +13,6 @@ local BUTTON_RID = "aaaaaaaa-bbbb-cccc-dddd-111111111111" local BUTTON_DEVICE_ID = "aaaaaaaa-bbbb-cccc-dddd-222222222222" local POWER_RID = "aaaaaaaa-bbbb-cccc-dddd-333333333333" --- Single button device fixture WITHOUT SSE (lifecycle only) local fixtures = hue_test_helpers.HueDeviceBuilder.new() :with_bridge() :with_button(BUTTON_RID, { @@ -23,14 +22,16 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() }) :start() -local mock_button = fixtures.devices[1] +local mock_bridge, mock_button = fixtures.bridge, fixtures.devices[1] -test.set_test_init_function(fixtures.test_init) +local scenario, conns = hue_test_helpers.create_hue_scenario() +hue_test_helpers.setup_button_init_expectations(conns.rest, conns.sse, fixtures.configs.button[1]) +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) test.register_coroutine_test( "a cached button is added and initialized from its discovery state", function() - test.wait_for_events() + hue_test_helpers.wait_for_init(scenario, mock_bridge) local button = fixtures.driver_device(mock_button) assert(button:get_field(Fields._ADDED) == true, "expected the button to have been added") assert(button:get_field(Fields.DEVICE_TYPE) == "button", "expected the button's device type") 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 90d9fd30b1..cd0946012f 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 @@ -23,21 +23,19 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() battery = 85, label = "Hue Button", device_id = BUTTON_DEVICE_ID, - power_rid = POWER_RID + power_rid = POWER_RID, + zigbee_rid = ZIGBEE_RID, }) - :enable_sse() :start() local mock_bridge, mock_button = fixtures.bridge, fixtures.devices[1] -- Create connection scenario with REST and SSE connections -local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local scenario, conns = hue_test_helpers.create_hue_scenario() local rest, sse = conns.rest, conns.sse -- Setup all button init expectations using device-specific helper --- Override zigbee_rid since the builder uses a default pattern local button_config = fixtures.configs.button[1] -button_config.zigbee_rid = ZIGBEE_RID hue_test_helpers.setup_button_init_expectations(rest, sse, button_config) -- Setup test init with scenario activation @@ -46,7 +44,7 @@ hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) test.register_coroutine_test( "the bridge's event stream connects and the button's init requests are made", function() - hue_test_helpers.assert_event_stream_connected(scenario, mock_bridge) + hue_test_helpers.wait_for_init(scenario, mock_bridge) end ) 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 1b5c7a0da8..f8ca572407 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 @@ -3,6 +3,7 @@ --- A child device whose resource state isn't in the discovery cache is treated as a "stray": --- it isn't onboarded (no REST state fetch, no device fields set) until the bridge reports it. +--- Here the bridge, scanned for it once the bridge initializes, doesn't have it. local test = require "integration_test" local t_utils = require "integration_test.utils" @@ -17,6 +18,7 @@ local NEW_LIGHT_RID = "44444444-4444-4444-4444-444444444444" local fixtures = hue_test_helpers.HueDeviceBuilder.new() :with_bridge() :with_light(CACHED_LIGHT_RID, { on = { on = true } }, "white-and-color-ambiance.yml") + :expect_light_init_refreshes() :start() local mock_bridge, mock_cached_light = fixtures.bridge, fixtures.devices[1] @@ -29,23 +31,47 @@ local mock_new_light = test.mock_device.build_test_lan_device({ }) local scenario, conns = hue_test_helpers.create_hue_scenario() +hue_test_helpers.setup_light_init_expectations(conns.rest, fixtures.configs.light[1]) +hue_test_helpers.setup_sse_expectations(conns.sse, conns.rest) +-- With a stray waiting, the bridge initializing has the driver scan the bridge's devices for it. +-- The bridge only has the cached light. +local cached_light = fixtures.configs.light[1] +http.expect_request(conns.rest, "GET", "/clip/v2/resource/device", { + body = { errors = {}, data = { { + type = "device", + id = cached_light.device_id, + id_v1 = "/lights/1", + metadata = { name = cached_light.label, archetype = "sultan_bulb" }, + product_data = { manufacturer_name = "Signify Netherlands B.V.", model_id = "TEST", product_name = cached_light.label }, + services = { + { rtype = "light", rid = cached_light.rid }, + { rtype = "zigbee_connectivity", rid = cached_light.zigbee_rid }, + }, + } } }, + reusable = true, +}) hue_test_helpers.setup_scenario_test_init(function() fixtures.test_init() test.mock_device.add_test_device(mock_new_light) + -- The stray light initializes while the cached light's refreshes wait on the bridge, so the + -- two lights' events interleave; only each light's own events are in a fixed order. + test.socket.capability:__set_channel_ordering("relaxed") -- Light init emits levelRange whether or not the light was onboarded. - hue_test_helpers.expect_light_init_events(mock_new_light) + hue_test_helpers.expect_light_init_events(mock_new_light, "white-and-color-ambiance.yml") end, scenario) test.register_coroutine_test( "a child device with no cached resource state is left as a stray, not onboarded", function() - test.wait_for_events() + hue_test_helpers.wait_for_init(scenario, mock_bridge) local stray = fixtures.driver_device(mock_new_light) assert(stray:get_field(Fields._ADDED) == nil, "expected the stray light not to have been added") assert(stray:get_field(Fields.HUE_DEVICE_ID) == nil and stray:get_field(Fields.DEVICE_TYPE) == nil, "expected no Hue device fields on the stray light") + assert(http.count_requests(conns.rest, "GET", "/clip/v2/resource/device") >= 1, + "expected the driver to look for the stray light on the bridge once the bridge initialized") assert(http.count_requests(conns.rest, "GET", "/clip/v2/resource/light/" .. NEW_LIGHT_RID) == 0, "expected no state request for the stray light") 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 33282dd300..7f935f18b8 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 @@ -23,20 +23,19 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() tamper = "not_tampered", device_id = CONTACT_DEVICE_ID, power_rid = POWER_RID, + zigbee_rid = ZIGBEE_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 scenario, conns = hue_test_helpers.create_hue_scenario() local rest, sse = conns.rest, conns.sse -- Setup all contact sensor init expectations using device-specific helper local contact_config = fixtures.configs.contact[1] -contact_config.zigbee_rid = ZIGBEE_RID hue_test_helpers.setup_contact_init_expectations(rest, sse, contact_config) -- Setup test init with scenario activation @@ -45,7 +44,7 @@ hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) test.register_coroutine_test( "the bridge's event stream connects and the contact sensor's init requests are made", function() - hue_test_helpers.assert_event_stream_connected(scenario, mock_bridge) + hue_test_helpers.wait_for_init(scenario, mock_bridge) 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 354254ef4f..d94a38d019 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 @@ -38,13 +38,12 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() }, "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 scenario, conns = hue_test_helpers.create_hue_scenario() local rest, sse = conns.rest, conns.sse -- The events emitted for the recorded lamp's light resource on a refresh. The colour temperature @@ -84,8 +83,10 @@ http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. LAMP_RID, { }) 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 }) +hue_test_helpers.expect_zigbee_connectivity(rest, OTHER_ZIGBEE_RID, OTHER_DEVICE_ID, { reusable = true }) +hue_test_helpers.expect_resource(rest, "light", OTHER_RID, { + owner = { rid = OTHER_DEVICE_ID, rtype = "device" }, on = { on = true }, dimming = { brightness = 80 }, +}, { reusable = true }) http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) 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 1ed98691ed..d44467f4c9 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 @@ -12,6 +12,7 @@ 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 HueApi = require "hue.api" local HueDeviceTypes = require "hue_device_types" local ids = capture_payloads.ids @@ -33,12 +34,11 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() 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 scenario, conns = hue_test_helpers.create_hue_scenario() local rest, sse = conns.rest, conns.sse http.expect_request(rest, "GET", "/clip/v2/resource/device/" .. ids.color_lamp_device, { @@ -51,9 +51,16 @@ http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. ids.color_lamp_li status = 200, body = lamp_light_resource(), reusable = true, }) --- The fields the earlier `added` (and first `init`) left persisted. +-- The fields the earlier `added` (and first `init`) left persisted. A restart is the one case where +-- a device starts with state the driver didn't build in this run: the hub hands it the fields the +-- driver persisted before the restart, so seeding them is how the test models that, rather than +-- something to drive through the protocol. local function seed_persisted_fields() mock_bridge:set_field(Fields._ADDED, true, { persist = true }) + mock_bridge:set_field(Fields.DEVICE_TYPE, HueDeviceTypes.BRIDGE, { persist = true }) + mock_bridge:set_field(Fields.BRIDGE_ID, hue_test_helpers.BRIDGE_DNI, { persist = true }) + mock_bridge:set_field(Fields.IPV4, hue_test_helpers.BRIDGE_IP, { persist = true }) + mock_bridge:set_field(HueApi.APPLICATION_KEY_HEADER, hue_test_helpers.API_KEY, { 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 }) 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 6606087e2a..0c87715f23 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 @@ -8,6 +8,7 @@ --- test on any it isn't expecting), and the driver must keep working afterwards. 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" @@ -23,12 +24,16 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() dimming = { brightness = 100 }, hue_device_id = LIGHT_DEVICE_ID, }, "white-and-color-ambiance.yml") + :expect_light_init_refreshes() :start() local mock_bridge, mock_light = fixtures.bridge, fixtures.devices[1] local scenario, conns = hue_test_helpers.create_hue_scenario() -local rest = conns.rest +local rest, sse = conns.rest, conns.sse + +hue_test_helpers.setup_light_init_expectations(rest, fixtures.configs.light[1]) +hue_test_helpers.setup_sse_expectations(sse, rest) hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) @@ -40,13 +45,6 @@ local function send_command(capability, command, args) test.wait_for_events() end -local function queue_command(capability, command, args) - test.socket.capability:__queue_receive({ - mock_light.id, - { capability = capability, component = "main", command = command, args = args or {} }, - }) -end - local function hue_error(description, type) return { errors = { { type = type, description = description } }, data = {} } end @@ -63,6 +61,7 @@ end test.register_coroutine_test( "a light command answered with 404 emits nothing and doesn't stop later commands", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "PUT", LIGHT_PATH, { request_json = { on = { on = true } }, status = 404, @@ -76,6 +75,7 @@ test.register_coroutine_test( test.register_coroutine_test( "a light command answered with 500 emits nothing and doesn't stop later commands", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "PUT", LIGHT_PATH, { request_json = { dimming = { brightness = 50 } }, status = 500, @@ -89,6 +89,7 @@ test.register_coroutine_test( test.register_coroutine_test( "a light command answered with a Hue API error emits nothing and doesn't stop later commands", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "PUT", LIGHT_PATH, { request_json = { on = { on = true } }, body = hue_error("Light is unreachable"), @@ -98,41 +99,57 @@ test.register_coroutine_test( end ) +local CONNECTIVITY_PATH = "/clip/v2/resource/zigbee_connectivity" + +--- Refresh the light with its device GET answered by `device_response`: the refresh reads the +--- light's state next, without querying connectivity, and still emits it. +local function assert_refresh_skips_connectivity(device_response, why) + hue_test_helpers.wait_for_init(scenario, mock_bridge) + local before = http.count_requests(rest, "GET", CONNECTIVITY_PATH, { prefix = true }) + test.socket.capability:__queue_receive({ + mock_light.id, { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) + + http.await_request(rest, "GET", DEVICE_PATH):respond(device_response) + http.await_request(rest, "GET", LIGHT_PATH):respond({ body = hue_test_helpers.light_resource(fixtures.configs.light[1]) }) + for _, event in ipairs({ + capabilities["samsungim.hueSyncMode"].mode("normal"), + capabilities.switch.switch.on(), + capabilities.switchLevel.level(100), + }) do + test.socket.capability:__expect_send(mock_light:generate_test_message("main", event)) + end + test.wait_for_events() + assert(http.count_requests(rest, "GET", CONNECTIVITY_PATH, { prefix = true }) == before, + "expected no connectivity request " .. why) +end + test.register_coroutine_test( "a refresh of a device without a zigbee_connectivity service doesn't query connectivity", function() - hue_test_helpers.mark_bridge_initialized(mock_bridge) - queue_command("refresh", "refresh") - http.await_request(rest, "GET", DEVICE_PATH):respond({ + assert_refresh_skips_connectivity({ body = { errors = {}, - data = { { id = LIGHT_DEVICE_ID, services = { { rtype = "light", rid = LIGHT_RID } } } }, + data = { { id = LIGHT_DEVICE_ID, type = "device", services = { { rtype = "light", rid = LIGHT_RID } } } }, }, - }) - test.wait_for_events() - assert(http.count_requests(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { prefix = true }) == 0, - "expected no connectivity request without a zigbee_connectivity service") + }, "without a zigbee_connectivity service") end ) test.register_coroutine_test( "a refresh of a device the bridge no longer has (404) doesn't query connectivity", function() - hue_test_helpers.mark_bridge_initialized(mock_bridge) - queue_command("refresh", "refresh") - http.await_request(rest, "GET", DEVICE_PATH):respond({ + assert_refresh_skips_connectivity({ status = 404, body = hue_error("Device not found", "resource_not_found"), - }) - test.wait_for_events() - assert(http.count_requests(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { prefix = true }) == 0, - "expected no connectivity request for a device the bridge doesn't have") + }, "for a device the bridge doesn't have") end ) test.register_coroutine_test( "a command answered with malformed JSON emits nothing and doesn't stop later commands", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) rest:expect_for_test({ request = { pattern = "^PUT " .. hue_test_helpers.escape_uuid(LIGHT_PATH) }, responses = { @@ -147,6 +164,7 @@ test.register_coroutine_test( test.register_coroutine_test( "a command answered with an empty body emits nothing and doesn't stop later commands", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) rest:expect_for_test({ request = { pattern = "^PUT " .. hue_test_helpers.escape_uuid(LIGHT_PATH) }, responses = { @@ -161,11 +179,17 @@ test.register_coroutine_test( test.register_coroutine_test( "a command on a keep-alive connection the bridge has since closed is resent on a new connection", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) -- 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 OK = { body = { data = { { rid = LIGHT_RID, rtype = "light" } }, errors = {} } } + local function switch_on() + test.socket.capability:__queue_receive({ + mock_light.id, { capability = "switch", component = "main", command = "on", args = {} }, + }) + end - queue_command("switch", "on") + switch_on() local first = http.await_request(rest, "PUT", LIGHT_PATH) first:respond(OK) test.wait_for_events() @@ -175,7 +199,7 @@ test.register_coroutine_test( -- both requests up front. rest:close_connection() local puts = http.expect_request(rest, "PUT", LIGHT_PATH, { defer = true, times = 2 }) - queue_command("switch", "on") + switch_on() local stale = puts:await() assert(stale.connection_index == first.connection_index, "expected the command to go out first on the connection the bridge had closed") 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 5b061c753c..d35ba4cc5d 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 @@ -42,12 +42,11 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() 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 scenario, conns = hue_test_helpers.create_hue_scenario() local rest, sse = conns.rest, conns.sse -- The events emitted for the recorded light resource on a refresh. The colour temperature range @@ -72,7 +71,7 @@ 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 }) + hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID, HUE_DEVICE_ID, { reusable = false }) http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. LIGHT_RID, { status = 200, body = light_resource(), diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua index bebdc83c70..bc268a262b 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua @@ -23,18 +23,17 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() color_temperature = COLOR_TEMPERATURE, hue_device_id = HUE_DEVICE_ID, }, "white-ambiance.yml") - :enable_sse() :start() local mock_light = fixtures.devices[1] -local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local scenario, conns = hue_test_helpers.create_hue_scenario() local rest, sse = conns.rest, conns.sse -- The refreshes injected by the light's added and init handlers. hue_test_helpers.expect_device_info(rest, HUE_DEVICE_ID, { { rtype = "zigbee_connectivity", rid = ZIGBEE_RID } }, { reusable = true }) -hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID, { owner = HUE_DEVICE_ID, reusable = true }) +hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID, HUE_DEVICE_ID, { reusable = true }) http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. LIGHT_RID, { body = { errors = {}, data = { { id = LIGHT_RID, 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 6859f537c6..4c5694a63e 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 @@ -7,28 +7,53 @@ 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 capabilities = require "st.capabilities" local LIGHT_RID = "11111111-1111-1111-1111-111111111111" +-- The colour temperature, hue and saturation the driver reports for the light state below. +local INIT_KELVIN, INIT_HUE, INIT_SATURATION = 2732, 65, 12 + +local GAMUT = { red = { x = 0.7, y = 0.3 }, green = { x = 0.2, y = 0.7 }, blue = { x = 0.15, y = 0.05 } } + +--- The events a refresh of the light emits for its state below. The colour temperature range is +--- only emitted when it changes, i.e. on the first refresh. +local function refresh_events(first_refresh) + local events = { + capabilities["samsungim.hueSyncMode"].mode("normal"), + capabilities.switch.switch.on(), + capabilities.switchLevel.level(100), + } + if first_refresh then + table.insert(events, capabilities.colorTemperature.colorTemperatureRange({ minimum = 2000, maximum = 6500 })) + end + table.insert(events, capabilities.colorTemperature.colorTemperature(INIT_KELVIN)) + table.insert(events, capabilities.colorControl.hue(INIT_HUE)) + table.insert(events, capabilities.colorControl.saturation(INIT_SATURATION)) + return events +end + 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 } }, + color = { xy = { x = 0.3, y = 0.3 }, gamut = GAMUT, gamut_type = "other" }, + color_temperature = { mirek = 366, mirek_valid = true, mirek_schema = { mirek_minimum = 153, mirek_maximum = 500 } }, mode = "normal", + refresh_events = refresh_events, }, "white-and-color-ambiance.yml") + :expect_light_init_refreshes() :start() -local mock_light = fixtures.devices[1] +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() -local rest = conns.rest +local rest, sse = conns.rest, conns.sse + +hue_test_helpers.setup_light_init_expectations(rest, fixtures.configs.light[1]) +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) local LIGHT_PATH = "/clip/v2/resource/light/" .. LIGHT_RID @@ -48,6 +73,16 @@ local function assert_xy_near(xy, x, y, what) string.format("expected %s near (%.4f, %.4f), got (%.4f, %.4f)", what, x, y, xy.x, xy.y)) end +--- Have the bridge report (over SSE) that the light's colour changed to its gamut's red corner: +--- hue 0 at full saturation. (The light's init refresh left it at hue INIT_HUE, saturation +--- INIT_SATURATION.) +local function report_colour_red() + http.queue_sse_event(sse, { hue_test_helpers.light_event(LIGHT_RID, nil, nil, { color = { xy = { x = 0.7, y = 0.3 } } }) }) + test.socket.capability:__expect_send(mock_light:generate_test_message("main", capabilities.colorControl.hue(0))) + test.socket.capability:__expect_send(mock_light:generate_test_message("main", capabilities.colorControl.saturation(100))) + test.wait_for_events() +end + -- NOTE: Profile compatibility is implicitly tested here. The white-and-color-ambiance profile -- supports all capabilities: switch, switchLevel, colorControl, and colorTemperature. @@ -57,6 +92,7 @@ end test.register_coroutine_test( "switch on command sends a PUT to turn the light on", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) -- Test-specific expectation: PUT command with OK response http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { status = 200, @@ -77,6 +113,7 @@ test.register_coroutine_test( test.register_coroutine_test( "switch off command sends a PUT to turn the light off", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { status = 200, body = { data = { { rid = LIGHT_RID, rtype = "light" } } } @@ -96,6 +133,7 @@ test.register_coroutine_test( test.register_coroutine_test( "setLevel command sends a PUT with the requested brightness", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { status = 200, body = { data = { { rid = LIGHT_RID, rtype = "light" } } } @@ -115,6 +153,7 @@ test.register_coroutine_test( test.register_coroutine_test( "setColorTemperature command sends a PUT with the mirek conversion of the requested Kelvin value", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { status = 200, body = { data = { { rid = LIGHT_RID, rtype = "light" } } } @@ -134,6 +173,7 @@ test.register_coroutine_test( test.register_coroutine_test( "setColor command converts HSV to XY and sends PUT with color", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { status = 200, body = { data = { { rid = LIGHT_RID, rtype = "light" } } } @@ -154,40 +194,42 @@ test.register_coroutine_test( test.register_coroutine_test( "setHue combines the new hue with the light's current saturation", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { status = 200, body = { data = { { rid = LIGHT_RID, rtype = "light" } } } }) - fixtures.driver_device(mock_light):set_field(Fields.COLOR_SATURATION, 100) + report_colour_red() test.socket.capability:__queue_receive({ mock_light.id, - { capability = "colorControl", component = "main", command = "setHue", args = { 0 } }, + { capability = "colorControl", component = "main", command = "setHue", args = { 100 / 3 } }, }) test.wait_for_events() - -- Hue 0 at full saturation is pure red: the red corner of the light's gamut. - assert_xy_near(sent_xy(), 0.7, 0.3, "fully saturated red") + -- A third of the way round at full saturation is pure green: the green corner of the gamut. + assert_xy_near(sent_xy(), GAMUT.green.x, GAMUT.green.y, "fully saturated green") end ) test.register_coroutine_test( "setSaturation combines the new saturation with the light's current hue", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { status = 200, body = { data = { { rid = LIGHT_RID, rtype = "light" } } } }) - fixtures.driver_device(mock_light):set_field(Fields.COLOR_HUE, 33) + report_colour_red() test.socket.capability:__queue_receive({ mock_light.id, - { capability = "colorControl", component = "main", command = "setSaturation", args = { 0 } }, + { capability = "colorControl", component = "main", command = "setSaturation", args = { 100 } }, }) test.wait_for_events() - -- No saturation is white whatever the hue: Hue's D65 white point. - assert_xy_near(sent_xy(), 0.3227, 0.3290, "white") + -- Hue 0 at full saturation is pure red: the red corner of the light's gamut. + assert_xy_near(sent_xy(), 0.7, 0.3, "fully saturated red") end ) @@ -203,6 +245,7 @@ end test.register_coroutine_test( "setLevel 0 is clamped to the minimum brightness of 1", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) expect_light_put() test.socket.capability:__queue_receive({ mock_light.id, @@ -221,6 +264,7 @@ for _, case in ipairs({ test.register_coroutine_test( string.format("setColorTemperature %dK is clamped to the light's range (mirek %d)", case.kelvin, case.mirek), function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) expect_light_put() test.socket.capability:__queue_receive({ mock_light.id, 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 63cfdcab3f..5101c78b02 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 @@ -1,10 +1,11 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +--- A refresh command reads the light's state from the bridge and emits it. + 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" @@ -16,59 +17,47 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() on = { on = true }, dimming = { brightness = 80 }, hue_device_id = HUE_DEVICE_ID, + zigbee_rid = ZIGBEE_RID, }, "white-and-color-ambiance.yml") + :expect_light_init_refreshes() :start() local mock_bridge, mock_light = fixtures.bridge, fixtures.devices[1] --- Set up ConnectionScenario for REST-only testing local scenario, conns = hue_test_helpers.create_hue_scenario() -local rest = conns.rest - --- Setup test init with scenario activation -hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) +local rest, sse = conns.rest, conns.sse --- Define refresh sequence expectations --- During refresh, the driver queries device info (to get zigbee_connectivity RID), --- then checks zigbee connectivity, then queries light state +-- The light's init refreshes and the event stream. A refresh queries the light's device (to find +-- its zigbee_connectivity resource), then its connectivity, then its state; the device and +-- connectivity expectations are reusable, so they answer the tests' refreshes too. +hue_test_helpers.setup_light_init_expectations(rest, fixtures.configs.light[1]) +hue_test_helpers.setup_sse_expectations(sse, rest) --- 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 } -) +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) --- 2. GET zigbee connectivity status (reusable across tests) -hue_test_helpers.expect_zigbee_connectivity(rest, ZIGBEE_RID, - { owner = HUE_DEVICE_ID, reusable = true } -) +local function refresh_with_light_state(on, brightness) + hue_test_helpers.wait_for_init(scenario, mock_bridge) + hue_test_helpers.expect_resource(rest, "light", LIGHT_RID, { + owner = { rid = HUE_DEVICE_ID, rtype = "device" }, + on = { on = on }, + dimming = { brightness = brightness }, + }) --- Note: Light state expectations are test-specific and defined in each test body + test.socket.capability:__queue_receive({ + mock_light.id, + { capability = "refresh", component = "main", command = "refresh", args = {} }, + }) +end 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 = {} }, - }) - + refresh_with_light_state(true, 60) 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)) + mock_light:generate_test_message("main", capabilities.switchLevel.level(60)) ) test.wait_for_events() end @@ -77,22 +66,7 @@ test.register_coroutine_test( 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 = {} }, - }) - + refresh_with_light_state(false, 15) test.socket.capability:__expect_send( mock_light:generate_test_message("main", capabilities.switch.switch.off()) ) 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 3be464186c..bbeda1c5bb 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 @@ -24,21 +24,20 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() light_level = 30000, -- ~1000 lux device_id = MOTION_DEVICE_ID, power_rid = POWER_RID, + zigbee_rid = ZIGBEE_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 scenario, conns = hue_test_helpers.create_hue_scenario() local rest, sse = conns.rest, conns.sse -- Setup all motion sensor init expectations using device-specific helper local motion_config = fixtures.configs.motion[1] -motion_config.zigbee_rid = ZIGBEE_RID hue_test_helpers.setup_motion_init_expectations(rest, sse, motion_config) -- Setup test init with scenario activation @@ -47,7 +46,7 @@ hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) test.register_coroutine_test( "the bridge's event stream connects and the motion sensor's init requests are made", function() - hue_test_helpers.assert_event_stream_connected(scenario, mock_bridge) + hue_test_helpers.wait_for_init(scenario, mock_bridge) end ) 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 f1bdcf6039..0aba87c52d 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 @@ -23,21 +23,20 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() battery = 90, device_id = BUTTON_DEVICE_ID, power_rid = POWER_RID, + zigbee_rid = ZIGBEE_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_remote = fixtures.devices[1] -- Set up ConnectionScenario for this host:port -local scenario, conns = hue_test_helpers.create_hue_scenario({ sse = true }) +local scenario, conns = hue_test_helpers.create_hue_scenario() local rest, sse = conns.rest, conns.sse -- Setup all button init expectations using device-specific helper local button_config = fixtures.configs.button[1] -button_config.zigbee_rid = ZIGBEE_RID hue_test_helpers.setup_button_init_expectations(rest, sse, button_config) -- Setup test init with scenario activation 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 f54fda0832..e2a711e137 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua @@ -25,36 +25,41 @@ local fixtures = hue_test_helpers.HueDeviceBuilder.new() :with_light(PLUG_RID, { on = { on = true }, hue_device_id = PLUG_DEVICE_ID, + zigbee_rid = PLUG_ZIGBEE_RID, label = "Hue smart plug 1", }, "plug.yml") + :expect_light_init_refreshes() :start() local mock_bridge, mock_plug = fixtures.bridge, fixtures.devices[1] local scenario, conns = hue_test_helpers.create_hue_scenario() -local rest = conns.rest - -hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) +local rest, sse = conns.rest, conns.sse +-- The plug's init refreshes are answered with its recorded resources. http.expect_request(rest, "GET", "/clip/v2/resource/device/" .. PLUG_DEVICE_ID, { status = 200, body = capture_payloads.get("plug_device"), + reusable = true, }) -hue_test_helpers.expect_zigbee_connectivity(rest, PLUG_ZIGBEE_RID, { owner = PLUG_DEVICE_ID, reusable = true }) +hue_test_helpers.expect_zigbee_connectivity(rest, PLUG_ZIGBEE_RID, PLUG_DEVICE_ID, { reusable = true }) +http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. PLUG_RID, { body = plug_light_resource(), times = 2 }) +hue_test_helpers.setup_sse_expectations(sse, rest) + +hue_test_helpers.setup_scenario_test_init(fixtures.test_init, scenario) local function refresh() test.socket.capability:__queue_receive({ mock_plug.id, { capability = "refresh", component = "main", command = "refresh", args = {} }, }) - test.socket.capability:__set_channel_ordering("relaxed") test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities["samsungim.hueSyncMode"].mode("normal"))) end test.register_coroutine_test( "refreshing a plug emits its switch and sync mode state", function() - hue_test_helpers.mark_bridge_initialized(mock_bridge) + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. PLUG_RID, { status = 200, body = plug_light_resource(), @@ -69,7 +74,7 @@ test.register_coroutine_test( test.register_coroutine_test( "refreshing a plug that is off emits switch off", function() - hue_test_helpers.mark_bridge_initialized(mock_bridge) + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "GET", "/clip/v2/resource/light/" .. PLUG_RID, { status = 200, body = plug_light_resource(false), @@ -85,6 +90,7 @@ for _, command in ipairs({ "on", "off" }) do test.register_coroutine_test( "switch " .. command .. " sends a PUT to the plug's light resource", function() + hue_test_helpers.wait_for_init(scenario, mock_bridge) http.expect_request_for_test(rest, "PUT", "/clip/v2/resource/light/" .. PLUG_RID, { status = 200, body = { data = { { rid = PLUG_RID, rtype = "light" } }, errors = {} }, From 711ea589f390ad04882317f946dc52b3af1ae809 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Tue, 20 Oct 2026 10:47:59 -0500 Subject: [PATCH 18/22] philips-hue: Fail the LAN tests when a driver handler raises The driver catches errors from capability command handlers (utils.safe_wrap_handler) and from light state updates, and only logs them. The dispatcher does the same for a sub-driver's can_handle, and a device thread for its watched-socket handlers. A test could pass while a handler crashed, as the button lifecycle test did before the fixtures ran against an initialized bridge. hue_test_helpers now wraps log.log before the driver loads. The driver's logjam routes every level function through it, keeping the log.log it finds as its real printer. Each fixture's test_init registers an end-of-test check (fail_on_caught_handler_errors), so a test fails if any of those errors was logged while it ran. A test that expects one can allow it with allow_handler_errors. The discovery tests, which don't use the builder, register the check from their own test_init. Co-Authored-By: Claude Opus 5.5 --- .../philips-hue/src/test/hue_test_helpers.lua | 75 +++++++++++++++++++ .../src/test/test_hue_bridge_discovery.lua | 9 +-- 2 files changed, 79 insertions(+), 5 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 401bdb726d..ed49381a4a 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -30,6 +30,9 @@ local HueApi = require "hue.api" --- `setup_*_init_expectations` helpers register what each device type requests while it --- initializes; `wait_for_init` waits for all of that. SSE event builders (`button_event`, --- `motion_event`, ...) build the payloads to queue with `connection_scenario_http.queue_sse_event`. +--- +--- Every test also fails if a driver handler raised an error that was caught and only logged (see +--- `fail_on_caught_handler_errors`). -- `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. @@ -41,6 +44,77 @@ M.BRIDGE_IP = "192.168.1.15" M.BRIDGE_DNI = "AA:BB:CC:DD:EE:FF" M.API_KEY = "test-api-key" +--- Errors a driver handler raised that were caught and only logged, so a test can't otherwise see +--- them: the driver's own `utils.safe_wrap_handler` and light event emitter catch errors from +--- capability command handlers and light state updates, the dispatcher catches errors from a +--- sub-driver's `can_handle`, and a device thread catches errors from its watched-socket handlers. +--- (Errors in lifecycle handlers and cosock threads already fail the test, since the test framework +--- runs the driver with `_fail_on_error`.) +local CAUGHT_HANDLER_ERROR_PATTERNS = { + "Failed to invoke capability command handler", + "Failed to invoke emit light status handler", + "Encountered error calling can_handle", + "handler on .- thread encountered error", +} + +-- The caught handler errors logged during the current test, and the patterns of the ones it +-- expects (see `allow_handler_errors`). +local caught_handler_errors = {} +local allowed_handler_errors = {} + +local function is_allowed_handler_error(message) + for _, pattern in ipairs(allowed_handler_errors) do + if message:find(pattern) then return true end + end + return false +end + +-- Every log call reaches `log.log`: the driver's logjam module routes each level function through +-- it once the driver loads, keeping the `log.log` it found as the real printer. Wrap it before +-- the driver loads (every Hue test file requires this module first) so this sees them all. +do + local log = require "log" + local original_log = log.log + log.log = function(opts, level, ...) + if level == "error" or level == "warn" then + local parts = table.pack(...) + for i = 1, parts.n do parts[i] = tostring(parts[i]) end + local message = table.concat(parts, " ", 1, parts.n) + for _, pattern in ipairs(CAUGHT_HANDLER_ERROR_PATTERNS) do + if message:find(pattern) and not is_allowed_handler_error(message) then + table.insert(caught_handler_errors, message) + break + end + end + end + return original_log(opts, level, ...) + end +end + +--- Fail the current test at its end if a driver handler raised an error that the driver or the +--- framework caught and only logged. Called from every fixture's `test_init`, so there is no need +--- to call it directly unless a test file builds its fixture by hand. +function M.fail_on_caught_handler_errors() + caught_handler_errors = {} + allowed_handler_errors = {} + test.mock_lan_socket.add_end_of_test_check("hue caught handler errors", function() + local problems = {} + for _, message in ipairs(caught_handler_errors) do + table.insert(problems, "a driver handler raised an error that was caught and logged: " .. message) + end + return problems + end) +end + +--- Let the current test's driver handlers raise errors matching `pattern` (a Lua pattern matched +--- against the logged message) without failing it, for a test that expects one. +--- +--- @param pattern string +function M.allow_handler_errors(pattern) + table.insert(allowed_handler_errors, pattern) +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 @@ -506,6 +580,7 @@ function HueDeviceBuilder:start() end local function test_init() + M.fail_on_caught_handler_errors() test.mock_device.add_test_device(mock_bridge) for _, mock_child in ipairs(mock_children) do test.mock_device.add_test_device(mock_child) 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 072ad7eec2..3819711b03 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 @@ -2,10 +2,9 @@ -- Licensed under the Apache License, Version 2.0 local test = require "integration_test" --- File-level test modes, as in test.hue_test_helpers: LAN driver tests run with test coroutine --- priority (discovery rescans every second until stopped), on the batched capability receive path. -test.set_test_coroutine_priority(true) -test.set_capability_batch_receive(true) +-- Sets the file-level test modes every Hue test runs with (LAN driver tests need test coroutine +-- priority: discovery rescans every second until stopped). +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 mock_mdns = test.mock_mdns @@ -27,7 +26,7 @@ test.add_test_env_setup_func(function(driver) Discovery.disco_api_instances = {} end) - +test.set_test_init_function(hue_test_helpers.fail_on_caught_handler_errors) --- 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 From 80b9e3d3b47105b2a71b8ebf4db522edb9423198 Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Tue, 3 Nov 2026 10:42:03 -0600 Subject: [PATCH 19/22] philips-hue: Require API version 23 for the LAN integration tests The tests use the integration test framework's LAN support, which is new in lua_libs API version 23, so skip them on older versions. Co-Authored-By: Claude Opus 5.5 --- .../src/test/test_hue_batch_commands.lua | 36 ++++++++++++------- .../test/test_hue_bridge_connect_scans.lua | 11 +++--- .../src/test/test_hue_bridge_discovery.lua | 9 +++-- .../test/test_hue_bridge_resource_events.lua | 9 +++-- .../src/test/test_hue_bridge_sse.lua | 18 ++++++---- .../src/test/test_hue_button_lifecycle.lua | 3 +- .../src/test/test_hue_button_sse.lua | 12 ++++--- .../test/test_hue_child_device_lifecycle.lua | 3 +- .../src/test/test_hue_contact_sensor_sse.lua | 18 ++++++---- .../src/test/test_hue_device_connectivity.lua | 15 +++++--- .../src/test/test_hue_driver_restart.lua | 3 +- .../src/test/test_hue_error_handling.lua | 24 ++++++++----- .../src/test/test_hue_light_color.lua | 3 +- .../test/test_hue_light_color_temperature.lua | 3 +- .../src/test/test_hue_light_commands.lua | 27 +++++++++----- .../src/test/test_hue_light_refresh.lua | 6 ++-- .../src/test/test_hue_motion_sensor_sse.lua | 18 ++++++---- .../src/test/test_hue_multibutton_sse.lua | 12 ++++--- .../philips-hue/src/test/test_hue_plug.lua | 9 +++-- 19 files changed, 158 insertions(+), 81 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 424c1f723b..f8d2134542 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 @@ -129,7 +129,8 @@ test.register_coroutine_test( assert_requests("PUT", LIGHT_PATH .. LIGHT_1_RID, 1) assert_requests("PUT", GROUPED_LIGHT_PATH, 0) http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_1_RID, { on = { on = true } }) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -147,7 +148,8 @@ test.register_coroutine_test( assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 1) assert_requests("PUT", LIGHT_PATH, 0) http.assert_sent_json(rest, "PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, { on = { on = true } }) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -167,7 +169,8 @@ test.register_coroutine_test( 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 + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -186,7 +189,8 @@ test.register_coroutine_test( 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 + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -207,7 +211,8 @@ test.register_coroutine_test( assert_requests("PUT", GROUPED_LIGHT_PATH, 0) http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_1_RID, { on = { on = true } }) http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_2_RID, { on = { on = false } }) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -227,7 +232,8 @@ test.register_coroutine_test( assert_requests("PUT", LIGHT_PATH .. LIGHT_2_RID, 1) assert_requests("PUT", GROUPED_LIGHT_PATH, 0) http.assert_sent_json(rest, "PUT", LIGHT_PATH .. LIGHT_1_RID, { dimming_delta = { action = "up", brightness_delta = 10 } }) - end + end, + { min_api_version = 23 } ) local function set_level_cmd(level) @@ -251,7 +257,8 @@ test.register_coroutine_test( 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 + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -273,7 +280,8 @@ test.register_coroutine_test( test.wait_for_events() assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 2) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -294,7 +302,8 @@ test.register_coroutine_test( test.wait_for_events() assert_requests("PUT", GROUPED_LIGHT_PATH .. GROUPED_LIGHT_RID, 2) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -311,7 +320,8 @@ test.register_coroutine_test( assert_requests("PUT", GROUPED_LIGHT_PATH .. ZONE_GROUPED_LIGHT_RID, 1) assert_requests("PUT", LIGHT_PATH, 0) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -327,7 +337,8 @@ test.register_coroutine_test( assert_requests("PUT", LIGHT_PATH .. LIGHT_3_RID, 1) assert_requests("PUT", GROUPED_LIGHT_PATH, 0) - end + end, + { min_api_version = 23 } ) --- Run `fn`, returning the messages logged with `log.error_with` while it ran. @@ -368,7 +379,8 @@ test.register_coroutine_test( if message:find("bridge is busy", 1, true) then logged = true end end assert(logged, "expected the bridge's error to be logged, got: " .. table.concat(messages, " | ")) - end + end, + { min_api_version = 23 } ) test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua index 3f19ef89bd..fb14bd83fd 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua @@ -72,7 +72,7 @@ test.register_coroutine_test( assert(third > first, string.format( "expected the driver to back off between attempts, but all three were made at t=%s", first)) end, - { test_init = answer_connectivity_scans(3) } + { min_api_version = 23, test_init = answer_connectivity_scans(3) } ) test.register_coroutine_test( @@ -84,7 +84,7 @@ test.register_coroutine_test( assert(third > first, string.format( "expected the driver to back off between attempts, but all three were made at t=%s", first)) end, - { test_init = answer_room_scans(3) } + { min_api_version = 23, test_init = answer_room_scans(3) } ) test.register_coroutine_test( @@ -94,6 +94,7 @@ test.register_coroutine_test( http.await_request(rest, "GET", ROOM_PATH, { max_turns = 500 }):respond({ body = EMPTY }) end, { + min_api_version = 23, test_init = function() base_test_init() http.expect_request(rest, "GET", CONNECTIVITY_PATH, { body = EMPTY }) @@ -122,7 +123,7 @@ test.register_coroutine_test( -- Another attempt would be an unexpected request. let_time_pass(120) end, - { test_init = answer_connectivity_scans(2) } + { min_api_version = 23, test_init = answer_connectivity_scans(2) } ) test.register_coroutine_test( @@ -133,7 +134,7 @@ test.register_coroutine_test( remove_bridge() let_time_pass(120) end, - { test_init = answer_room_scans(2) } + { min_api_version = 23, test_init = answer_room_scans(2) } ) test.register_coroutine_test( @@ -148,7 +149,7 @@ test.register_coroutine_test( -- A further attempt by the earlier connection's scan would be an unexpected request. let_time_pass(120) end, - { test_init = answer_connectivity_scans(3) } + { min_api_version = 23, test_init = answer_connectivity_scans(3) } ) test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_discovery.lua index 3819711b03..a162d54439 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 @@ -95,7 +95,8 @@ test.register_coroutine_test( test.wait_for_events() assert_bridge_queried(bridge, api) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -126,7 +127,8 @@ test.register_coroutine_test( test.wait_for_events() assert_bridge_queried(bridge, api) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -171,7 +173,8 @@ test.register_coroutine_test( assert_bridge_queried(paired, paired_api) assert_bridge_queried(unpaired, unpaired_api) - end + end, + { min_api_version = 23 } ) 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 index db99ea09ef..4500f275bd 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 @@ -87,7 +87,8 @@ test.register_coroutine_test( http.queue_sse_event(conn, capture_payloads.get("color_lamp_added")) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -108,7 +109,8 @@ test.register_coroutine_test( 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 + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -120,7 +122,8 @@ test.register_coroutine_test( http.queue_sse_event(conn, capture_payloads.get("plug_deleted")) test.wait_until(function() return mock_devices_api.__is_device_deleted(mock_plug.id) end, { message = "the plug being deleted after the bridge reported its light resource deleted" }) - end + end, + { min_api_version = 23 } ) 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 index 82c4b5fab3..87e9982117 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua @@ -106,7 +106,8 @@ test.register_coroutine_test( "SSE connect marks the bridge online", function() connect_sse() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -124,7 +125,8 @@ test.register_coroutine_test( mock_light:generate_test_message("main", capabilities.switchLevel.level(42)) ) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -140,7 +142,8 @@ test.register_coroutine_test( mock_light:generate_test_message("main", capabilities.switchLevel.level(42)) ) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -197,7 +200,8 @@ test.register_coroutine_test( }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -212,7 +216,8 @@ test.register_coroutine_test( 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 + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -245,7 +250,8 @@ test.register_coroutine_test( "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 + end, + { min_api_version = 23 } ) 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 index a9f9fd841d..51f269b16d 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 @@ -36,7 +36,8 @@ test.register_coroutine_test( assert(button:get_field(Fields._ADDED) == true, "expected the button to have been added") assert(button:get_field(Fields.DEVICE_TYPE) == "button", "expected the button's device type") assert(button:get_field(Fields.HUE_DEVICE_ID) == BUTTON_DEVICE_ID, "expected the button's Hue device id") - end + end, + { min_api_version = 23 } ) 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 index cd0946012f..5459e0813f 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 @@ -45,7 +45,8 @@ test.register_coroutine_test( "the bridge's event stream connects and the button's init requests are made", function() hue_test_helpers.wait_for_init(scenario, mock_bridge) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -60,7 +61,8 @@ test.register_coroutine_test( http.queue_sse_event(sse_conn, { hue_test_helpers.button_event(BUTTON_RID, "short_release") }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -75,7 +77,8 @@ test.register_coroutine_test( http.queue_sse_event(sse_conn, { hue_test_helpers.button_event(BUTTON_RID, "long_press") }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -100,7 +103,8 @@ test.register_coroutine_test( ) }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) 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 index f8ca572407..907f62d2a4 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 @@ -78,7 +78,8 @@ test.register_coroutine_test( local cached = fixtures.driver_device(mock_cached_light) assert(cached:get_field(Fields._ADDED) == true and cached:get_field(Fields.DEVICE_TYPE) == "light", "expected the cached light to have been onboarded") - end + end, + { min_api_version = 23 } ) 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 index 7f935f18b8..d9074badfc 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 @@ -45,7 +45,8 @@ test.register_coroutine_test( "the bridge's event stream connects and the contact sensor's init requests are made", function() hue_test_helpers.wait_for_init(scenario, mock_bridge) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -58,7 +59,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.contact_event(CONTACT_RID, "no_contact") }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -71,7 +73,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.contact_event(CONTACT_RID, "contact") }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -84,7 +87,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.tamper_event(TAMPER_RID, "tampered") }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -97,7 +101,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.tamper_event(TAMPER_RID, "not_tampered") }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -119,7 +124,8 @@ test.register_coroutine_test( ) }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_device_connectivity.lua index d94a38d019..584b937c4d 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 @@ -168,7 +168,8 @@ test.register_coroutine_test( assert_online(mock_lamp, false, "the lamp") assert_online(mock_other, true, "the other light") assert_online(mock_bridge, true, "the bridge") - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -185,7 +186,8 @@ test.register_coroutine_test( test.wait_for_events() assert_online(mock_lamp, true, "the lamp") - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -217,7 +219,8 @@ test.register_coroutine_test( wait_until(function() return is_online(mock_bridge) == true end) wait_until(function() return is_online(mock_lamp) and is_online(mock_other) end) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -248,7 +251,8 @@ test.register_coroutine_test( assert(mock_devices_api.__count_device_status_calls(mock_bridge.id, false) == 1, "expected the bridge to be reported offline once for the outage") wait_until(function() return is_online(mock_bridge) == true end) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -268,7 +272,8 @@ test.register_coroutine_test( -- The third reconnect is answered by the file's handshake, and the stream is back. wait_until(function() return is_online(mock_bridge) == true end) assert_offline_calls(1) - end + end, + { min_api_version = 23 } ) 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 index d44467f4c9..ba879f2f49 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 @@ -119,7 +119,8 @@ test.register_coroutine_test( -- One refresh: the device, connectivity and light GETs happen once each. local light_gets = http.count_requests(rest, "GET", "/clip/v2/resource/light/" .. ids.color_lamp_light) assert(light_gets == 1, string.format("expected 1 light refresh, got %d", light_gets)) - end + end, + { min_api_version = 23 } ) 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 index 0c87715f23..c83f61cb8a 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 @@ -69,7 +69,8 @@ test.register_coroutine_test( }) send_command("switch", "on") assert_next_command_sent() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -83,7 +84,8 @@ test.register_coroutine_test( }) send_command("switchLevel", "setLevel", { 50 }) assert_next_command_sent() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -96,7 +98,8 @@ test.register_coroutine_test( }) send_command("switch", "on") assert_next_command_sent() - end + end, + { min_api_version = 23 } ) local CONNECTIVITY_PATH = "/clip/v2/resource/zigbee_connectivity" @@ -133,7 +136,8 @@ test.register_coroutine_test( data = { { id = LIGHT_DEVICE_ID, type = "device", services = { { rtype = "light", rid = LIGHT_RID } } } }, }, }, "without a zigbee_connectivity service") - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -143,7 +147,8 @@ test.register_coroutine_test( status = 404, body = hue_error("Device not found", "resource_not_found"), }, "for a device the bridge doesn't have") - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -158,7 +163,8 @@ test.register_coroutine_test( }) send_command("switch", "on") assert_next_command_sent() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -173,7 +179,8 @@ test.register_coroutine_test( }) send_command("switchLevel", "setLevel", { 25 }) assert_next_command_sent() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -208,7 +215,8 @@ test.register_coroutine_test( "expected the command to be resent on a new connection") resent:respond(OK) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color.lua index d35ba4cc5d..ee072268bf 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 @@ -121,7 +121,8 @@ test.register_coroutine_test( test.wait_for_events() http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { color = { xy = { x = 0.2151, y = 0.7106 } } }) - end + end, + { min_api_version = 23 } ) test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua index bc268a262b..408cf9102c 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua @@ -87,7 +87,8 @@ test.register_coroutine_test( test.socket.capability:__expect_send( mock_light:generate_test_message("main", capabilities.colorTemperature.colorTemperature(4000))) test.wait_for_events() - end + end, + { min_api_version = 23 } ) 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 4c5694a63e..5e49ff8183 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 @@ -107,7 +107,8 @@ test.register_coroutine_test( -- Verify the request body contains the expected on=true http.assert_sent_json(rest, "PUT", LIGHT_PATH, { on = { on = true } }) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -127,7 +128,8 @@ test.register_coroutine_test( -- Verify the request body contains the expected on=false http.assert_sent_json(rest, "PUT", LIGHT_PATH, { on = { on = false } }) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -147,7 +149,8 @@ test.register_coroutine_test( -- Verify the request body contains the expected brightness http.assert_sent_json(rest, "PUT", LIGHT_PATH, { dimming = { brightness = 42 } }) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -167,7 +170,8 @@ test.register_coroutine_test( -- Verify the request body contains the expected mirek value (3000K = 333 mirek) http.assert_sent_json(rest, "PUT", LIGHT_PATH, { color_temperature = { mirek = 333 }, on = { on = true } }) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -188,7 +192,8 @@ test.register_coroutine_test( -- Hue 0 at full saturation is pure red: the red corner of the light's gamut. assert_xy_near(sent_xy(), 0.7, 0.3, "fully saturated red") http.assert_sent_json(rest, "PUT", LIGHT_PATH, { on = { on = true } }) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -209,7 +214,8 @@ test.register_coroutine_test( -- A third of the way round at full saturation is pure green: the green corner of the gamut. assert_xy_near(sent_xy(), GAMUT.green.x, GAMUT.green.y, "fully saturated green") - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -230,7 +236,8 @@ test.register_coroutine_test( -- Hue 0 at full saturation is pure red: the red corner of the light's gamut. assert_xy_near(sent_xy(), 0.7, 0.3, "fully saturated red") - end + end, + { min_api_version = 23 } ) -- Out-of-range values sent from the SmartThings API during physical testing (scenario 15), and the @@ -254,7 +261,8 @@ test.register_coroutine_test( test.wait_for_events() http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { dimming = { brightness = 1 } }) - end + end, + { min_api_version = 23 } ) for _, case in ipairs({ @@ -274,7 +282,8 @@ for _, case in ipairs({ http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. LIGHT_RID, { color_temperature = { mirek = case.mirek }, on = { on = true } }) - end + end, + { min_api_version = 23 } ) end 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 5101c78b02..896ac13495 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 @@ -60,7 +60,8 @@ test.register_coroutine_test( mock_light:generate_test_message("main", capabilities.switchLevel.level(60)) ) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -74,7 +75,8 @@ test.register_coroutine_test( mock_light:generate_test_message("main", capabilities.switchLevel.level(15)) ) test.wait_for_events() - end + end, + { min_api_version = 23 } ) 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 index bbeda1c5bb..59a1d3d421 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 @@ -47,7 +47,8 @@ test.register_coroutine_test( "the bridge's event stream connects and the motion sensor's init requests are made", function() hue_test_helpers.wait_for_init(scenario, mock_bridge) - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -60,7 +61,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.motion_event(MOTION_RID, true) }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -73,7 +75,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.motion_event(MOTION_RID, false) }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -86,7 +89,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.temperature_event(TEMP_RID, 22.5) }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -101,7 +105,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.light_level_event(LIGHT_RID, 27000) }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -129,7 +134,8 @@ test.register_coroutine_test( ) }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) 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 index 0aba87c52d..cda198e086 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 @@ -52,7 +52,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_1, "short_release") }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -65,7 +66,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_2, "long_press") }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -78,7 +80,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_3, "short_release") }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -91,7 +94,8 @@ test.register_coroutine_test( http.queue_sse_event(sse, { hue_test_helpers.button_event(BUTTON_RID_4, "long_press") }) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.run_registered_tests() diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua index e2a711e137..b7c3acae25 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua @@ -68,7 +68,8 @@ test.register_coroutine_test( refresh() test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities.switch.switch.on())) test.wait_for_events() - end + end, + { min_api_version = 23 } ) test.register_coroutine_test( @@ -83,7 +84,8 @@ test.register_coroutine_test( refresh() test.socket.capability:__expect_send(mock_plug:generate_test_message("main", capabilities.switch.switch.off())) test.wait_for_events() - end + end, + { min_api_version = 23 } ) for _, command in ipairs({ "on", "off" }) do @@ -103,7 +105,8 @@ for _, command in ipairs({ "on", "off" }) do test.wait_for_events() http.assert_sent_json(rest, "PUT", "/clip/v2/resource/light/" .. PLUG_RID, { on = { on = command == "on" } }) - end + end, + { min_api_version = 23 } ) end From 8d744c76ba385a81eccc2fb7e8b95ebfac7cc4aa Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Tue, 3 Nov 2026 10:44:23 -0600 Subject: [PATCH 20/22] philips-hue: Use the test framework's caught-error and mock time helpers - The tests wrapped log.log to fail a test whose handler raised an error that was caught and only logged. The framework now does this for LAN test files (set_fail_on_caught_errors, including the errors st catches), so give it the driver's own caught-error messages instead. - let_time_pass, which waited on os.time() with a large turn budget, is the framework's test.let_time_pass, which doesn't overshoot. - Waits for scan requests and the group scan use mock-time budgets (within = ...) based on the driver's backoff and the group scan's debounce, instead of max_turns = 500. Co-Authored-By: Claude Opus 5.5 --- .../philips-hue/src/test/hue_test_helpers.lua | 83 +++---------------- .../src/test/test_hue_batch_commands.lua | 2 +- .../test/test_hue_bridge_connect_scans.lua | 26 +++--- .../src/test/test_hue_bridge_discovery.lua | 2 - 4 files changed, 25 insertions(+), 88 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 ed49381a4a..96c51593f1 100644 --- a/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua +++ b/drivers/SmartThings/philips-hue/src/test/hue_test_helpers.lua @@ -9,6 +9,15 @@ local t_utils = require "integration_test.utils" -- batched capability receive path that current hubs always provide. test.set_test_coroutine_priority(true) test.set_capability_batch_receive(true) +-- Fail a test if a driver handler raised an error that was caught and only logged: as well as the +-- errors `st` catches, the driver's own wrappers (`utils.safe_wrap_handler` and the light event +-- emitter) catch and log errors from capability command handlers and light state updates. +test.set_fail_on_caught_errors({ + patterns = { + "Failed to invoke capability command handler", + "Failed to invoke emit light status handler", + }, +}) local connection_scenario = require "integration_test.connection_scenario" local http = require "integration_test.connection_scenario_http" local capabilities = require "st.capabilities" @@ -32,7 +41,7 @@ local HueApi = require "hue.api" --- `motion_event`, ...) build the payloads to queue with `connection_scenario_http.queue_sse_event`. --- --- Every test also fails if a driver handler raised an error that was caught and only logged (see ---- `fail_on_caught_handler_errors`). +--- `integration_test.set_fail_on_caught_errors`, set up below). -- `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. @@ -44,77 +53,6 @@ M.BRIDGE_IP = "192.168.1.15" M.BRIDGE_DNI = "AA:BB:CC:DD:EE:FF" M.API_KEY = "test-api-key" ---- Errors a driver handler raised that were caught and only logged, so a test can't otherwise see ---- them: the driver's own `utils.safe_wrap_handler` and light event emitter catch errors from ---- capability command handlers and light state updates, the dispatcher catches errors from a ---- sub-driver's `can_handle`, and a device thread catches errors from its watched-socket handlers. ---- (Errors in lifecycle handlers and cosock threads already fail the test, since the test framework ---- runs the driver with `_fail_on_error`.) -local CAUGHT_HANDLER_ERROR_PATTERNS = { - "Failed to invoke capability command handler", - "Failed to invoke emit light status handler", - "Encountered error calling can_handle", - "handler on .- thread encountered error", -} - --- The caught handler errors logged during the current test, and the patterns of the ones it --- expects (see `allow_handler_errors`). -local caught_handler_errors = {} -local allowed_handler_errors = {} - -local function is_allowed_handler_error(message) - for _, pattern in ipairs(allowed_handler_errors) do - if message:find(pattern) then return true end - end - return false -end - --- Every log call reaches `log.log`: the driver's logjam module routes each level function through --- it once the driver loads, keeping the `log.log` it found as the real printer. Wrap it before --- the driver loads (every Hue test file requires this module first) so this sees them all. -do - local log = require "log" - local original_log = log.log - log.log = function(opts, level, ...) - if level == "error" or level == "warn" then - local parts = table.pack(...) - for i = 1, parts.n do parts[i] = tostring(parts[i]) end - local message = table.concat(parts, " ", 1, parts.n) - for _, pattern in ipairs(CAUGHT_HANDLER_ERROR_PATTERNS) do - if message:find(pattern) and not is_allowed_handler_error(message) then - table.insert(caught_handler_errors, message) - break - end - end - end - return original_log(opts, level, ...) - end -end - ---- Fail the current test at its end if a driver handler raised an error that the driver or the ---- framework caught and only logged. Called from every fixture's `test_init`, so there is no need ---- to call it directly unless a test file builds its fixture by hand. -function M.fail_on_caught_handler_errors() - caught_handler_errors = {} - allowed_handler_errors = {} - test.mock_lan_socket.add_end_of_test_check("hue caught handler errors", function() - local problems = {} - for _, message in ipairs(caught_handler_errors) do - table.insert(problems, "a driver handler raised an error that was caught and logged: " .. message) - end - return problems - end) -end - ---- Let the current test's driver handlers raise errors matching `pattern` (a Lua pattern matched ---- against the logged message) without failing it, for a test that expects one. ---- ---- @param pattern string -function M.allow_handler_errors(pattern) - table.insert(allowed_handler_errors, pattern) -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 @@ -580,7 +518,6 @@ function HueDeviceBuilder:start() end local function test_init() - M.fail_on_caught_handler_errors() test.mock_device.add_test_device(mock_bridge) for _, mock_child in ipairs(mock_children) do test.mock_device.add_test_device(mock_child) 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 f8d2134542..a24b894f8d 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 @@ -111,7 +111,7 @@ local function init_and_scan_groups() local bridge = fixtures.driver_device(mock_bridge) test.wait_until(function() return http.count_requests(rest, "GET", "/clip/v2/resource/zone") >= 1 and bridge:get_field(Fields.GROUPS) ~= nil - end, { max_turns = 500, message = "the room and zone scan" }) + end, { within = 120, message = "the room and zone scan" }) -- the scan waits 45s for more devices assert(http.count_requests(rest, "GET", "/clip/v2/resource/room") >= 1, "expected the room scan") -- Back to strict ordering for the events of the test itself. test.socket.capability:__set_channel_ordering("strict") diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua index fb14bd83fd..9056ce18be 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua @@ -36,9 +36,14 @@ local FAILED = { errors = { { description = "internal error" } } } -- fails the test. local scans +-- The longest the driver waits before a retry: its backoff grows to 30s, plus up to 0.5s of jitter. +local RETRY_WITHIN = 60 +-- The group scan starts once 45s pass with no further scan queued. +local GROUP_SCAN_WITHIN = 120 + --- Wait for the driver's next scan request, answer it, and return the mock time it was made at. local function answer_next(response) - local req = scans:await({ max_turns = 500 }) + local req = scans:await({ within = RETRY_WITHIN }) local made_at = os.time() req:respond(response) return made_at @@ -91,7 +96,7 @@ test.register_coroutine_test( "a connectivity scan reporting no devices is complete: it isn't repeated, and the room scan follows", function() -- The connectivity scan is answered once (a second one would be an unexpected request). - http.await_request(rest, "GET", ROOM_PATH, { max_turns = 500 }):respond({ body = EMPTY }) + http.await_request(rest, "GET", ROOM_PATH, { within = GROUP_SCAN_WITHIN }):respond({ body = EMPTY }) end, { min_api_version = 23, @@ -102,12 +107,6 @@ test.register_coroutine_test( } ) ---- Let mock time run on for `seconds`, well past the driver's longest backoff. -local function let_time_pass(seconds) - local until_time = os.time() + seconds - test.wait_until(function() return os.time() >= until_time end, - { max_turns = 500, message = string.format("%ds of mock time passing", seconds) }) -end local function remove_bridge() test.socket.device_lifecycle:__queue_receive({ fixtures.bridge.id, "removed" }) @@ -121,7 +120,8 @@ test.register_coroutine_test( answer_next({ status = 500, body = FAILED }) remove_bridge() -- Another attempt would be an unexpected request. - let_time_pass(120) + -- Well past the driver's longest backoff. + test.let_time_pass(120) end, { min_api_version = 23, test_init = answer_connectivity_scans(2) } ) @@ -132,7 +132,8 @@ test.register_coroutine_test( answer_next({ status = 500, body = FAILED }) answer_next({ status = 500, body = FAILED }) remove_bridge() - let_time_pass(120) + -- Well past the driver's longest backoff. + test.let_time_pass(120) end, { min_api_version = 23, test_init = answer_room_scans(2) } ) @@ -145,9 +146,10 @@ test.register_coroutine_test( -- The stream drops and reconnects; the new connection's scan succeeds, and the room scan follows. sse:close_connection() answer_next({ body = CONNECTED }) - http.await_request(rest, "GET", ROOM_PATH, { max_turns = 500 }):respond({ body = EMPTY }) + http.await_request(rest, "GET", ROOM_PATH, { within = GROUP_SCAN_WITHIN }):respond({ body = EMPTY }) -- A further attempt by the earlier connection's scan would be an unexpected request. - let_time_pass(120) + -- Well past the driver's longest backoff. + test.let_time_pass(120) end, { min_api_version = 23, test_init = answer_connectivity_scans(3) } ) 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 a162d54439..188feb424d 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 @@ -26,8 +26,6 @@ test.add_test_env_setup_func(function(driver) Discovery.disco_api_instances = {} end) -test.set_test_init_function(hue_test_helpers.fail_on_caught_handler_errors) - --- 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: From 6cb1fc76b8b2fd847313dde4f27a0b91c762ff0c Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Tue, 3 Nov 2026 11:10:12 -0600 Subject: [PATCH 21/22] philips-hue: Skip the LAN integration test files on older lua_libs min_api_version skips a test, but these files call the test framework's LAN support (set_test_coroutine_priority, add_capability_definition, connection_scenario) as they load, which fails with lua_libs older than API version 23 before any test is registered. Check the version first and skip the whole file. Co-Authored-By: Claude Opus 5.5 --- .../philips-hue/src/test/test_hue_batch_commands.lua | 4 ++++ .../philips-hue/src/test/test_hue_bridge_connect_scans.lua | 4 ++++ .../philips-hue/src/test/test_hue_bridge_discovery.lua | 4 ++++ .../philips-hue/src/test/test_hue_bridge_resource_events.lua | 4 ++++ .../SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua | 4 ++++ .../philips-hue/src/test/test_hue_button_lifecycle.lua | 4 ++++ .../SmartThings/philips-hue/src/test/test_hue_button_sse.lua | 4 ++++ .../philips-hue/src/test/test_hue_child_device_lifecycle.lua | 4 ++++ .../philips-hue/src/test/test_hue_contact_sensor_sse.lua | 4 ++++ .../philips-hue/src/test/test_hue_device_connectivity.lua | 4 ++++ .../philips-hue/src/test/test_hue_driver_restart.lua | 4 ++++ .../philips-hue/src/test/test_hue_error_handling.lua | 4 ++++ .../SmartThings/philips-hue/src/test/test_hue_light_color.lua | 4 ++++ .../philips-hue/src/test/test_hue_light_color_temperature.lua | 4 ++++ .../philips-hue/src/test/test_hue_light_commands.lua | 4 ++++ .../philips-hue/src/test/test_hue_light_refresh.lua | 4 ++++ .../philips-hue/src/test/test_hue_motion_sensor_sse.lua | 4 ++++ .../philips-hue/src/test/test_hue_multibutton_sse.lua | 4 ++++ drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua | 4 ++++ 19 files changed, 76 insertions(+) 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 a24b894f8d..ec36df26f0 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 @@ -7,6 +7,10 @@ --- bridge, which it runs (debounced) once the lights are added and the event stream connects. -- Batched receives are turned on for every Hue test file by test.hue_test_helpers. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local hue_test_helpers = require "test.hue_test_helpers" local http = require "integration_test.connection_scenario_http" diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua index 9056ce18be..110beb93a8 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_connect_scans.lua @@ -4,6 +4,10 @@ --- The scans the driver makes once the bridge's event stream connects: the Zigbee connectivity of --- its devices, followed by a (debounced) scan of its rooms and zones. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local hue_test_helpers = require "test.hue_test_helpers" local http = require "integration_test.connection_scenario_http" 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 188feb424d..022effa806 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 @@ -1,6 +1,10 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" -- Sets the file-level test modes every Hue test runs with (LAN driver tests need test coroutine -- priority: discovery rescans every second until stopped). 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 4500f275bd..9d61e81f87 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 @@ -5,6 +5,10 @@ --- 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. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local mock_devices_api = require "integration_test.mock_devices_api" 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 index 87e9982117..9632393393 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_bridge_sse.lua @@ -8,6 +8,10 @@ --- expectation registered after `activate()` in test_init, so a test can answer the scan after --- a reconnect differently. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local mock_devices_api = require "integration_test.mock_devices_api" 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 51f269b16d..06dbabb8d7 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 @@ -4,6 +4,10 @@ --- A button whose state is in the discovery cache is added and initialized from it, then --- refreshed from the bridge once the bridge has initialized. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local hue_test_helpers = require "test.hue_test_helpers" local Fields = require "fields" 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 5459e0813f..0b4c7b1253 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 @@ -4,6 +4,10 @@ --- Test for Hue button device with SSE events. --- Uses HueDeviceBuilder and ConnectionScenario 2.0 with device-type-specific helper. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" 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 907f62d2a4..d6c5b93a53 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 @@ -5,6 +5,10 @@ --- it isn't onboarded (no REST state fetch, no device fields set) until the bridge reports it. --- Here the bridge, scanned for it once the bridge initializes, doesn't have it. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local t_utils = require "integration_test.utils" local hue_test_helpers = require "test.hue_test_helpers" 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 d9074badfc..2f90e5cdb6 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 @@ -1,6 +1,10 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" 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 584b937c4d..3627c65bab 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 @@ -5,6 +5,10 @@ --- bridge's event stream. Built from payloads recorded on real hardware (see --- test/fixtures/capture_payloads.lua). +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local mock_devices_api = require "integration_test.mock_devices_api" 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 ba879f2f49..59e9c5b309 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 @@ -5,6 +5,10 @@ --- 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. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local mock_devices_api = require "integration_test.mock_devices_api" 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 c83f61cb8a..16c880545e 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 @@ -7,6 +7,10 @@ --- A command that fails must not emit any capability events (the capability channel fails the --- test on any it isn't expecting), and the driver must keep working afterwards. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" 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 ee072268bf..13f7d37d5f 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 @@ -4,6 +4,10 @@ --- Tests for colour state reported by the bridge, built from payloads recorded on real hardware --- (see test/fixtures/capture_payloads.lua). +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua index 408cf9102c..5d45f2afef 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_light_color_temperature.lua @@ -3,6 +3,10 @@ --- The colour temperature range of a light whose range isn't the driver's default. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" 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 5e49ff8183..805a727d52 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 @@ -4,6 +4,10 @@ --- Test for Hue light command handling (switch, level, color, temperature). --- Uses ConnectionScenario 2.0 for PUT command testing. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local hue_test_helpers = require "test.hue_test_helpers" local http = require "integration_test.connection_scenario_http" 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 896ac13495..8d745415b7 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 @@ -3,6 +3,10 @@ --- A refresh command reads the light's state from the bridge and emits it. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" 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 59a1d3d421..4d8fbe3451 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 @@ -1,6 +1,10 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" 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 cda198e086..1e532be344 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 @@ -1,6 +1,10 @@ -- Copyright 2026 SmartThings, Inc. -- Licensed under the Apache License, Version 2.0 +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" 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 b7c3acae25..6568013e4a 100644 --- a/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_plug.lua @@ -4,6 +4,10 @@ --- 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`. +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + local test = require "integration_test" local capabilities = require "st.capabilities" local hue_test_helpers = require "test.hue_test_helpers" From 62c50fab4df371fc78a378192bc160ff040721ac Mon Sep 17 00:00:00 2001 From: Zach Varberg Date: Tue, 3 Nov 2026 11:15:53 -0600 Subject: [PATCH 22/22] philips-hue: Test from bridge discovery through to a working light No test followed onboarding end to end: the discovery tests stop once the bridge device is created, and the device tests start from a bridge and lights that an earlier pairing left behind. Add one that runs it all against the mock bridge, with nothing pre-populated: mDNS discovery pairs with the bridge and creates its device; the hub adds the bridge, which connects its event stream; discovery finds the recorded colour lamp on the bridge and creates it as the bridge's child; the hub adds the lamp, which reads its state; and the lamp then turns off on a command and reports the bridge's state update. Co-Authored-By: Claude Opus 5.5 --- .../src/test/test_hue_discovery_to_device.lua | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 drivers/SmartThings/philips-hue/src/test/test_hue_discovery_to_device.lua diff --git a/drivers/SmartThings/philips-hue/src/test/test_hue_discovery_to_device.lua b/drivers/SmartThings/philips-hue/src/test/test_hue_discovery_to_device.lua new file mode 100644 index 0000000000..84f9f0e43f --- /dev/null +++ b/drivers/SmartThings/philips-hue/src/test/test_hue_discovery_to_device.lua @@ -0,0 +1,178 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 + +--- From discovery to a working light, end to end: mDNS finds a bridge and the driver pairs with it +--- and creates its device; the hub adds the bridge, which connects its event stream; discovery then +--- finds a colour lamp on the bridge and creates its device; the hub adds the lamp, which reads its +--- state; and the lamp then takes a command and reports the bridge's state update. Nothing is +--- pre-populated: every step runs for real against the mock bridge (payloads recorded on real +--- hardware, see test/fixtures/capture_payloads.lua). + +-- These tests use the integration test framework's LAN support, new in lua_libs API version 23. +-- With older lua_libs, skip the file rather than fail loading it. +if require("version").api < 23 then return end + +local test = require "integration_test" +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 capabilities = require "st.capabilities" +local t_utils = require "integration_test.utils" + +local Discovery = require "disco" + +local ids = capture_payloads.ids + +local BRIDGE_IP = "192.168.1.40" +-- The recorded bridge config's MAC, 00:17:88:00:00:01, with the separators stripped. +local BRIDGE_DNI = "001788000001" +local LAMP_PROFILE = "white-and-color-ambiance" + +test.add_test_env_setup_func(function(driver) + -- disco is a module-level singleton that persists across tests within this file. + Discovery.discovery_active = false + Discovery.api_keys = {} + Discovery.disco_api_instances = {} +end) + +local scenario, conns = hue_test_helpers.create_hue_scenario({ host = BRIDGE_IP }) +local rest, sse = conns.rest, conns.sse +-- Discovery's requests: the bridge's config, and an API key once the Link Button is pressed. +local api = scenario:connection("api", { matcher = http.matcher({ "GET", "POST" }, "^/api"), ordering = "relaxed" }) +http.expect_request(api, "GET", "/api/config", { body = capture_payloads.get("bridge_config"), reusable = true }) +http.expect_request(api, "POST", "/api", { + body = { { success = { username = "new-bridge-api-key", clientkey = "some-client-key" } } }, + reusable = true, +}) + +http.expect_sse_handshake(sse, "/eventstream/clip/v2", true) + +-- The bridge has one device, the recorded colour lamp. +local lamp_device = capture_payloads.get("color_lamp_device").data[1] +local lamp_zigbee = capture_payloads.get("color_lamp_zigbee").data[1] +http.expect_request(rest, "GET", "/clip/v2/resource/device", { + body = { errors = {}, data = { lamp_device } }, reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/device/" .. ids.color_lamp_device, { + body = capture_payloads.get("color_lamp_device"), reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/light/" .. ids.color_lamp_light, { + body = capture_payloads.get("color_lamp_light"), reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity", { + body = { errors = {}, data = { lamp_zigbee } }, reusable = true, +}) +http.expect_request(rest, "GET", "/clip/v2/resource/zigbee_connectivity/" .. ids.color_lamp_zigbee, { + body = capture_payloads.get("color_lamp_zigbee"), reusable = true, +}) + +test.set_test_init_function(function() + scenario:activate() +end) + +--- What the lamp reports when it reads its state from the bridge: once from `added` and again +--- from `init` (when only the colour temperature range, being unchanged, isn't sent again). +local function expect_lamp_state_events(lamp_mock, with_range) + local function expect(event) + test.socket.capability:__expect_send(lamp_mock:generate_test_message("main", event)) + end + expect(capabilities["samsungim.hueSyncMode"].mode("normal")) + expect(capabilities.switch.switch.on()) + expect(capabilities.switchLevel.level(100)) + if with_range then + expect(capabilities.colorTemperature.colorTemperatureRange({ minimum = 2000, maximum = 6500 })) + end + -- The recorded state: 366 mirek, and xy (0.4572, 0.4099) in gamut C. + expect(capabilities.colorTemperature.colorTemperature(2732)) + expect(capabilities.colorControl.hue(11)) + expect(capabilities.colorControl.saturation(53)) +end + +local function start_discovery() + test.mock_mdns.__queue_response(Discovery.ServiceType, Discovery.Domain, { + found = { + test.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 + +test.register_coroutine_test( + "a discovered bridge and the lamp found on it are created, set up, and the lamp then works", + function() + test.socket.capability:__set_channel_ordering("relaxed") + + local lamp + local bridge = test.mock_devices_api.__expect_create_device({ + type = "LAN", + deviceNetworkId = BRIDGE_DNI, + label = "Hue Bridge", + profileReference = "hue-bridge", + manufacturer = "Signify Netherlands B.V.", + model = "BSB002", + vendorProvidedLabel = "Hue Bridge", + }, { + adopt = { + profile = t_utils.get_profile_definition("hue-bridge.yml"), + -- Once the bridge exists, the lamp discovery finds on it is created as its child. + on_created = function(bridge_device) + lamp = test.mock_devices_api.__expect_create_device({ + type = "EDGE_CHILD", + label = "Hue color lamp 1", + profileReference = LAMP_PROFILE, + parentDeviceId = bridge_device.id, + manufacturer = "Signify Netherlands B.V.", + model = "LCT014", + parentAssignedChildKey = "light:" .. ids.color_lamp_light, + }, { + adopt = { + profile = t_utils.get_profile_definition(LAMP_PROFILE .. ".yml"), + on_created = function(lamp_mock) + expect_lamp_state_events(lamp_mock, true) -- added + hue_test_helpers.expect_light_init_events(lamp_mock, LAMP_PROFILE .. ".yml") + expect_lamp_state_events(lamp_mock, false) -- init + end, + }, + }) + end, + }, + }) + + -- Discovery pairs with the bridge on its first pass and finds the lamp on a later one. + start_discovery() + test.wait_until(function() return lamp ~= nil and lamp.device ~= nil end, + { within = 30, message = "the bridge and then the lamp being created" }) + test.socket.discovery:__queue_receive({ "stop" }) + test.wait_until(function() + return test.mock_devices_api.__is_device_online(bridge.device.id) == true + end, { within = 30, message = "the bridge's event stream connecting" }) + -- Let the lamp finish setting up (the batched capability receive delivers its state events). + test.wait_for_events() + assert(http.count_requests(rest, "GET", "/clip/v2/resource/light/" .. ids.color_lamp_light) >= 1, + "expected the lamp to read its state from the bridge") + + -- The lamp works: a command reaches the bridge, and the bridge's state update reaches the lamp. + local lamp_device = lamp.device + test.socket.capability:__set_channel_ordering("strict") + http.expect_request(rest, "PUT", "/clip/v2/resource/light/" .. ids.color_lamp_light, { + request_json = { on = { on = false } }, + body = { errors = {}, data = { { rid = ids.color_lamp_light, rtype = "light" } } }, + }) + test.socket.capability:__queue_receive({ + lamp_device.id, { capability = "switch", component = "main", command = "off", args = {} }, + }) + test.wait_for_events() + http.queue_sse_event(sse, { hue_test_helpers.light_event(ids.color_lamp_light, false) }) + test.socket.capability:__expect_send(lamp_device:generate_test_message("main", capabilities.switch.switch.off())) + test.wait_for_events() + end, + { min_api_version = 23 } +) + +test.run_registered_tests()