diff --git a/drivers/Copeland/sensi-st25z-zwave/config.yml b/drivers/Copeland/sensi-st25z-zwave/config.yml new file mode 100644 index 0000000000..3dacdf1e26 --- /dev/null +++ b/drivers/Copeland/sensi-st25z-zwave/config.yml @@ -0,0 +1,6 @@ +name: 'Sensi Lite Smart Thermostat Z-Wave' +packageKey: 'copeland-sensi-st25z-zwave' +permissions: + zwave: {} +description: "SmartThings driver for the Sensi Lite Smart Thermostat (Z-Wave), model 1F76U-22ZB" +vendorSupportInformation: "https://support.smartthings.com" diff --git a/drivers/Copeland/sensi-st25z-zwave/fingerprints.yml b/drivers/Copeland/sensi-st25z-zwave/fingerprints.yml new file mode 100644 index 0000000000..b12426def8 --- /dev/null +++ b/drivers/Copeland/sensi-st25z-zwave/fingerprints.yml @@ -0,0 +1,7 @@ +zwaveManufacturer: + - id: sensi-thermostat-zwave + deviceLabel: Sensi Lite Smart Thermostat Z-Wave + manufacturerId: 0x046B + productType: 0x0004 + productId: 0x0002 + deviceProfileName: thermostat-sensi-st25z \ No newline at end of file diff --git a/drivers/Copeland/sensi-st25z-zwave/profiles/st25z-thermostat.yml b/drivers/Copeland/sensi-st25z-zwave/profiles/st25z-thermostat.yml new file mode 100644 index 0000000000..1514650ea1 --- /dev/null +++ b/drivers/Copeland/sensi-st25z-zwave/profiles/st25z-thermostat.yml @@ -0,0 +1,45 @@ +name: thermostat-sensi-st25z +components: +- id: main + capabilities: + - id: temperatureMeasurement + version: 1 + - id: relativeHumidityMeasurement + version: 1 + - id: thermostatHeatingSetpoint + version: 1 + config: + values: + - key: "heatingSetpoint.value" + range: [ 6.99, 36.12 ] + step: 1 + - id: thermostatCoolingSetpoint + version: 1 + config: + values: + - key: "coolingSetpoint.value" + range: [ 8.33, 37.23 ] + step: 1 + - id: thermostatMode + version: 1 + - id: thermostatFanMode + version: 1 + - id: thermostatOperatingState + version: 1 + config: + values: + - key: "thermostatOperatingState.value" + enabledValues: + - heating + - cooling + - fan only + - idle + - pending heat + - pending cool + - vent economizer + - id: battery + version: 1 + - id: refresh + version: 1 + categories: + - name: Thermostat diff --git a/drivers/Copeland/sensi-st25z-zwave/src/init.lua b/drivers/Copeland/sensi-st25z-zwave/src/init.lua new file mode 100644 index 0000000000..d2dd271a73 --- /dev/null +++ b/drivers/Copeland/sensi-st25z-zwave/src/init.lua @@ -0,0 +1,65 @@ +-- Copyright 2022 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local capabilities = require "st.capabilities" +--- @type st.zwave.Driver +local ZwaveDriver = require "st.zwave.driver" +--- @type st.zwave.defaults +local defaults = require "st.zwave.defaults" +--- @type st.zwave.CommandClass +local cc = require "st.zwave.CommandClass" +--- @type st.zwave.CommandClass.ThermostatFanMode +local ThermostatFanMode = (require "st.zwave.CommandClass.ThermostatFanMode")({version=3}) +--- @type st.zwave.CommandClass.ThermostatMode +local ThermostatMode = (require "st.zwave.CommandClass.ThermostatMode")({version=2}) + +local function device_added(driver, device) + if device:supports_capability_by_id(capabilities.thermostatMode.ID) and + device:is_cc_supported(cc.THERMOSTAT_MODE) then + device:send(ThermostatMode:SupportedGet({})) + end + if device:supports_capability_by_id(capabilities.thermostatFanMode.ID) and + device:is_cc_supported(cc.THERMOSTAT_FAN_MODE) then + device:send(ThermostatFanMode:SupportedGet({})) + end + device:refresh() +end + +local driver_template = { + supported_capabilities = { + capabilities.temperatureAlarm, + capabilities.temperatureMeasurement, + capabilities.thermostatHeatingSetpoint, + capabilities.thermostatCoolingSetpoint, + capabilities.thermostatOperatingState, + capabilities.thermostatMode, + capabilities.thermostatFanMode, + capabilities.relativeHumidityMeasurement, + capabilities.battery, + capabilities.powerMeter, + capabilities.energyMeter + }, + lifecycle_handlers = { + added = device_added + }, + sub_drivers = require("sub_drivers") +} + +defaults.register_for_default_handlers( + driver_template, + driver_template.supported_capabilities, + { native_capability_attrs_enabled = true }) +--- @type st.zwave.Driver +local thermostat = ZwaveDriver("zwave_thermostat", driver_template) +thermostat:run() \ No newline at end of file diff --git a/drivers/Copeland/sensi-st25z-zwave/src/lazy_load_subdriver.lua b/drivers/Copeland/sensi-st25z-zwave/src/lazy_load_subdriver.lua new file mode 100644 index 0000000000..f904df1478 --- /dev/null +++ b/drivers/Copeland/sensi-st25z-zwave/src/lazy_load_subdriver.lua @@ -0,0 +1,27 @@ +-- Copyright 2025 SmartThings, Inc. +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +return function(sub_driver_name) + -- gets the current lua libs api version + local ZwaveDriver = require "st.zwave.driver" + local version = require "version" + + if version.api >= 16 then + return ZwaveDriver.lazy_load_sub_driver_v2(sub_driver_name) + elseif version.api >= 9 then + return ZwaveDriver.lazy_load_sub_driver(require(sub_driver_name)) + else + return require(sub_driver_name) + end +end diff --git a/drivers/Copeland/sensi-st25z-zwave/src/sub_drivers.lua b/drivers/Copeland/sensi-st25z-zwave/src/sub_drivers.lua new file mode 100644 index 0000000000..7ee7696e78 --- /dev/null +++ b/drivers/Copeland/sensi-st25z-zwave/src/sub_drivers.lua @@ -0,0 +1,5 @@ +local lazy_load_if_possible = require "lazy_load_subdriver" +local sub_drivers = { + lazy_load_if_possible("thermostat-sensi-st25z"), +} +return sub_drivers diff --git a/drivers/Copeland/sensi-st25z-zwave/src/test/test_thermostat_sensi_st25z.lua b/drivers/Copeland/sensi-st25z-zwave/src/test/test_thermostat_sensi_st25z.lua new file mode 100644 index 0000000000..e5cf892c29 --- /dev/null +++ b/drivers/Copeland/sensi-st25z-zwave/src/test/test_thermostat_sensi_st25z.lua @@ -0,0 +1,1122 @@ +-- Copyright 2026 SmartThings +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. + +local test = require "integration_test" +local capabilities = require "st.capabilities" +local zw = require "st.zwave" +local zw_test_utils = require "integration_test.zwave_test_utils" +local t_utils = require "integration_test.utils" +local utils = require "st.utils" +local constants = require "st.zwave.constants" + +local ThermostatMode = (require "st.zwave.CommandClass.ThermostatMode")({ version = 2 }) +local ThermostatSetpoint = (require "st.zwave.CommandClass.ThermostatSetpoint")({ version = 2 }) +local ThermostatFanMode = (require "st.zwave.CommandClass.ThermostatFanMode")({ version = 3 }) +local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ version = 5 }) +local ThermostatOperatingState = + (require "st.zwave.CommandClass.ThermostatOperatingState")({ version = 1 }) +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +local MultiChannelAssociation = + (require "st.zwave.CommandClass.MultiChannelAssociation")({ version = 3 }) + +-- Work around a lua_libs test-harness bug: without ST_CAPABILITY_JSON_DIR set +-- (it requires a SmartThings API token we don't have in this sandbox), +-- mock_capability_channel's fallback capability-JSON reconstruction reads a +-- live command object's `.arguments` field, but aware.lua only ever sets +-- `.arg_list` -- so every rebuilt capability ends up with an empty argument +-- list, and any setter command (setThermostatMode, setHeatingSetpoint, ...) +-- fails with "Too many args for .". Pre-seed the +-- channel's cache with the original bundled JSON (unresolved $ref schemas +-- validate permissively per capability_utils.validate_type, so this is safe) +-- so the buggy reconstruction path is never reached. +do + local mock_capability_channel = require "integration_test.mock_capability_channel" + local st_json = require "st.json" + + local function seed_capability_json(capability_id) + -- Build a standalone, fully-inlined (no $ref) capability object straight + -- from the bundled generated JSON, bypassing the shared cap_mt cache + -- entirely so this never touches (or is affected by) the buggy fetch. + local raw_json = require("st.capabilities.generated." .. capability_id) + local cap = capabilities.build_cap_from_json_string(raw_json, false) + + local inlined = { + id = cap.ID, name = cap.NAME, version = cap.version, attributes = {}, commands = {} + } + for key, value in pairs(cap) do + if key ~= "commands" and type(value) == "table" and value.schema ~= nil then + inlined.attributes[key] = { schema = value.schema } + if value.enumCommands then + inlined.attributes[key].enumCommands = value.enumCommands + end + end + end + for cmd_name, cmd in pairs(cap.commands) do + inlined.commands[cmd_name] = { name = cmd.NAME, arguments = cmd.arg_list or {} } + end + + mock_capability_channel:__load_capability_definition(capability_id, 1, st_json.encode(inlined)) + end + + seed_capability_json("thermostatMode") + seed_capability_json("thermostatFanMode") + seed_capability_json("thermostatHeatingSetpoint") + seed_capability_json("thermostatCoolingSetpoint") +end + +local thermostat_endpoints = { + { + command_classes = { + { value = zw.THERMOSTAT_MODE }, + { value = zw.THERMOSTAT_SETPOINT }, + { value = zw.THERMOSTAT_FAN_MODE }, + { value = zw.SENSOR_MULTILEVEL }, + { value = zw.THERMOSTAT_OPERATING_STATE }, + { value = zw.BATTERY }, + { value = zw.MULTI_CHANNEL_ASSOCIATION }, + } + } +} + +local mock_device = test.mock_device.build_test_zwave_device({ + profile = t_utils.get_profile_definition("st25z-thermostat.yml"), + zwave_endpoints = thermostat_endpoints, + zwave_manufacturer_id = 0x046B, + zwave_product_type = 0x0004, + zwave_product_id = 0x0002, +}) + +local function test_init() + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +---------------------------------------------------------------------- +-- Lifecycle: added +---------------------------------------------------------------------- + +test.register_message_test( + "Added lifecycle event should seed an idle operating state so provisioning doesn't hang", + { + { + channel = "device_lifecycle", + direction = "receive", + message = { mock_device.id, "added" } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.thermostatOperatingState.thermostatOperatingState({ value = "idle" })) + } + } +) + +---------------------------------------------------------------------- +-- Lifecycle: init - staggered request_sequence + late-guard retries +-- +-- device_init schedules, in this exact source order: +-- 9 request_sequence steps at 1.2, 2.3, 3.4, 4.5, 5.6, 6.7, 7.8, 8.9, 10.0s +-- then 7 individual guarded retries at 15.0, 12.0, 14.0, 16.0, 17.0, 13.0, 18.0s +-- (that creation order matters because the test's mock timer queue is FIFO: +-- each device.thread:call_with_delay() pops the next queued timer regardless +-- of its delay value.) +---------------------------------------------------------------------- + +local INIT_TIMER_DELAYS = { + 1.2, 2.3, 3.4, 4.5, 5.6, 6.7, 7.8, 8.9, 10.0, 15.0, 12.0, 14.0, 16.0, 17.0, 13.0, 18.0 +} + +local function queue_init_timers() + for _, delay in ipairs(INIT_TIMER_DELAYS) do + test.timer.__create_and_queue_test_time_advance_timer(delay, "oneshot") + end +end + +test.register_coroutine_test( + "Device init should stage Supported/current-value GETs on schedule, then fire " .. + "every late-guard retry when state is still missing", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + queue_init_timers() + + test.socket.device_lifecycle():__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + + test.mock_time.advance_time(1.2) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatMode:SupportedGet({})) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.1) -- 2.3 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatFanMode:SupportedGet({})) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.1) -- 3.4 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1 })) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.1) -- 4.5 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1 })) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.1) -- 5.6 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatMode:Get({})) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.1) -- 6.7 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatFanMode:Get({})) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.1) -- 7.8 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + SensorMultilevel:Get({ + sensor_type = SensorMultilevel.sensor_type.TEMPERATURE, + scale = SensorMultilevel.scale.CELSIUS })) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.1) -- 8.9 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatOperatingState:Get({})) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.1) -- 10.0 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + SensorMultilevel:Get({ + sensor_type = SensorMultilevel.sensor_type.RELATIVE_HUMIDITY, + scale = SensorMultilevel.scale.relative_humidity.PERCENTAGE })) + ) + test.wait_for_events() + + -- Nothing has reported state back yet, so every late guard should retry. + test.mock_time.advance_time(2.0) -- 12.0: heating setpoint retry + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1 })) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.0) -- 13.0: room temperature retry + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + SensorMultilevel:Get({ + sensor_type = SensorMultilevel.sensor_type.TEMPERATURE, + scale = SensorMultilevel.scale.FAHRENHEIT })) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.0) -- 14.0: cooling setpoint retry + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1 })) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.0) -- 15.0: mode retry + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatMode:Get({})) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.0) -- 16.0: operating state retry + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatOperatingState:Get({})) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.0) -- 17.0: fan mode retry + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatFanMode:Get({})) + ) + test.wait_for_events() + + test.mock_time.advance_time(1.0) -- 18.0: humidity retry + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + SensorMultilevel:Get({ + sensor_type = SensorMultilevel.sensor_type.RELATIVE_HUMIDITY, + scale = SensorMultilevel.scale.relative_humidity.PERCENTAGE })) + ) + end +) + +test.register_coroutine_test( + "Device init late-guard retries should NOT re-fire once the corresponding state is already known", + function() + test.socket.zwave:__set_channel_ordering("relaxed") + + -- Seed every piece of state a late guard checks for, via direct emit_event + -- (bypassing the protocol report handlers so we don't consume extra + -- entries from the shared mock timer queue, e.g. the COOLING_1 report + -- handler's own follow-up timer). + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 70, unit = "F" }))) + mock_device:emit_event( + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 70, unit = "F" })) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 75, unit = "F" }))) + mock_device:emit_event( + capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 75, unit = "F" })) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", capabilities.thermostatMode.thermostatMode("heat"))) + mock_device:emit_event(capabilities.thermostatMode.thermostatMode("heat")) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatOperatingState.thermostatOperatingState({ value = "heating" }))) + mock_device:emit_event( + capabilities.thermostatOperatingState.thermostatOperatingState({ value = "heating" })) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatFanMode.thermostatFanMode("auto"))) + mock_device:emit_event(capabilities.thermostatFanMode.thermostatFanMode("auto")) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.temperatureMeasurement.temperature({ value = 68, unit = "F" }))) + mock_device:emit_event( + capabilities.temperatureMeasurement.temperature({ value = 68, unit = "F" })) + + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.relativeHumidityMeasurement.humidity({ value = 45 }))) + mock_device:emit_event(capabilities.relativeHumidityMeasurement.humidity({ value = 45 })) + + test.wait_for_events() + + queue_init_timers() + test.socket.device_lifecycle():__queue_receive({ mock_device.id, "init" }) + test.wait_for_events() + + -- The first pass of GETs is unconditional (not guarded), so it still fires. + test.mock_time.advance_time(10.0) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatMode:SupportedGet({}))) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatFanMode:SupportedGet({}))) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1 }))) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1 }))) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatMode:Get({}))) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatFanMode:Get({}))) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + SensorMultilevel:Get({ + sensor_type = SensorMultilevel.sensor_type.TEMPERATURE, + scale = SensorMultilevel.scale.CELSIUS }))) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatOperatingState:Get({}))) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + SensorMultilevel:Get({ + sensor_type = SensorMultilevel.sensor_type.RELATIVE_HUMIDITY, + scale = SensorMultilevel.scale.relative_humidity.PERCENTAGE }))) + test.wait_for_events() + + -- Every late guard (12, 13, 14, 15, 16, 17, 18s) should now see state already + -- present and skip its retry: no further zwave sends are expected at all. + test.mock_time.advance_time(8.0) -- through 18.0 + test.wait_for_events() + end +) + +---------------------------------------------------------------------- +-- Thermostat mode capability commands +---------------------------------------------------------------------- + +test.register_message_test( + "Setting the thermostat mode should generate the appropriate ThermostatMode:Set " .. + "commands, including emergency heat -> AUXILIARY_HEAT", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, + { capability = "thermostatMode", command = "setThermostatMode", args = { "off" } } } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatMode:Set({ mode = ThermostatMode.mode.OFF })) + }, + { + channel = "capability", + direction = "receive", + message = { mock_device.id, + { capability = "thermostatMode", command = "setThermostatMode", args = { "heat" } } } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatMode:Set({ mode = ThermostatMode.mode.HEAT })) + }, + { + channel = "capability", + direction = "receive", + message = { mock_device.id, + { capability = "thermostatMode", command = "setThermostatMode", args = { "cool" } } } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatMode:Set({ mode = ThermostatMode.mode.COOL })) + }, + { + channel = "capability", + direction = "receive", + message = { mock_device.id, + { capability = "thermostatMode", command = "setThermostatMode", args = { "auto" } } } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatMode:Set({ mode = ThermostatMode.mode.AUTO })) + }, + { + channel = "capability", + direction = "receive", + message = { mock_device.id, + { capability = "thermostatMode", command = "setThermostatMode", + args = { "emergency heat" } } } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatMode:Set({ mode = ThermostatMode.mode.AUXILIARY_HEAT })) + }, + } +) + +test.register_coroutine_test( + "Setting the thermostat mode should poll ThermostatMode:Get 0.9s later to verify", + function() + test.timer.__create_and_queue_test_time_advance_timer(0.9, "oneshot") + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatMode", command = "setThermostatMode", args = { "heat" } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command( + mock_device, ThermostatMode:Set({ mode = ThermostatMode.mode.HEAT }))) + test.wait_for_events() + + test.mock_time.advance_time(0.9) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatMode:Get({}))) + end +) + +---------------------------------------------------------------------- +-- Thermostat fan mode capability commands +---------------------------------------------------------------------- + +test.register_message_test( + "Setting the fan mode should generate the appropriate ThermostatFanMode:Set " .. + "commands (on -> HIGH, auto -> AUTO_HIGH)", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, + { capability = "thermostatFanMode", command = "setThermostatFanMode", args = { "on" } } } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatFanMode:Set({ fan_mode = ThermostatFanMode.fan_mode.HIGH })) + }, + { + channel = "capability", + direction = "receive", + message = { mock_device.id, + { capability = "thermostatFanMode", command = "setThermostatFanMode", args = { "auto" } } } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatFanMode:Set({ fan_mode = ThermostatFanMode.fan_mode.AUTO_HIGH })) + }, + } +) + +test.register_coroutine_test( + "Setting the fan mode should poll ThermostatFanMode:Get 5.0s later to verify", + function() + test.timer.__create_and_queue_test_time_advance_timer(5.0, "oneshot") + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatFanMode", command = "setThermostatFanMode", args = { "on" } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatFanMode:Set({ fan_mode = ThermostatFanMode.fan_mode.HIGH }))) + test.wait_for_events() + + test.mock_time.advance_time(5.0) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatFanMode:Get({}))) + end +) + +---------------------------------------------------------------------- +-- Setpoints: no deadband conflict, wire-scale precision handling +---------------------------------------------------------------------- + +test.register_message_test( + "Setting the heating setpoint (no deadband conflict, default degF wire scale) " .. + "should send a whole-integer degF Set", + { + { + channel = "capability", + direction = "receive", + message = { mock_device.id, + { capability = "thermostatHeatingSetpoint", command = "setHeatingSetpoint", + args = { 24 } } } + }, + { + channel = "zwave", + direction = "send", + message = zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Set({ + setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1, + scale = ThermostatSetpoint.scale.FAHRENHEIT, + precision = 0, + value = 75 + })) + } + } +) + +test.register_coroutine_test( + "Setting the cooling setpoint (no deadband conflict, degC wire scale) should " .. + "send a 0.1-precision degC Set", + function() + mock_device:set_field( + constants.TEMPERATURE_SCALE, ThermostatSetpoint.scale.CELSIUS, { persist = true }) + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", command = "setCoolingSetpoint", args = { 12.34 } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Set({ + setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1, + scale = ThermostatSetpoint.scale.CELSIUS, + precision = 1, + value = 12.3, + size = 2 + }))) + end +) + +test.register_coroutine_test( + "Setting a whole-number degC cooling setpoint should not crash serialization " .. + "(regression: ThermostatSetpoint:Set size/precision mismatch)", + function() + mock_device:set_field( + constants.TEMPERATURE_SCALE, ThermostatSetpoint.scale.CELSIUS, { persist = true }) + + -- ThermostatSetpoint:Set auto-sizes its wire field from the *unscaled* + -- value. For a whole-number degC value like 20.0, the auto-detector sees + -- no fractional part and picks a 1-byte field -- but the driver always + -- requests precision=1 for degC, so the actual encoded integer is + -- 20.0 * 10 = 200, which overflows that byte and crashes string.pack. + -- The driver now forces size=2 explicitly for degC to avoid this. + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", command = "setCoolingSetpoint", args = { 20 } } + }) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Set({ + setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1, + scale = ThermostatSetpoint.scale.CELSIUS, + precision = 1, + value = 20.0, + size = 2 + }))) + end +) + +---------------------------------------------------------------------- +-- Deadband: push-opposing-setpoint-first, in both directions +---------------------------------------------------------------------- + +test.register_coroutine_test( + "Heating setpoint request that would violate the deadband should bump COOLING " .. + "first, then HEATING, landing on exactly a 2degF gap", + function() + -- Current cooling setpoint is 75degF. Requesting 24degC (~75degF) heat + -- would leave a 0degF gap, so the driver must push cooling up to 77degF + -- first and land heat at 75degF: a clean 2degF gap, adjacent violation. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 75, unit = "F" }))) + mock_device:emit_event( + capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 75, unit = "F" })) + test.wait_for_events() + + for _, delay in ipairs({ 0.4, 1.1, 1.8, 2.5 }) do + test.timer.__create_and_queue_test_time_advance_timer(delay, "oneshot") + end + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatHeatingSetpoint", command = "setHeatingSetpoint", args = { 24 } } + }) + test.wait_for_events() + + test.mock_time.advance_time(0.4) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Set({ + setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1, + scale = ThermostatSetpoint.scale.FAHRENHEIT, precision = 0, value = 77 + }))) + test.wait_for_events() + + test.mock_time.advance_time(0.7) -- 1.1 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Set({ + setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1, + scale = ThermostatSetpoint.scale.FAHRENHEIT, precision = 0, value = 75 + }))) + test.wait_for_events() + + test.mock_time.advance_time(0.7) -- 1.8 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1 }))) + test.wait_for_events() + + test.mock_time.advance_time(0.7) -- 2.5 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1 }))) + end +) + +test.register_coroutine_test( + "Cooling setpoint request with a larger jump that would violate the deadband " .. + "should bump HEATING first, then COOLING", + function() + -- Current heating setpoint is 70degF. Requesting a cooling setpoint of + -- ~60degF (a 10degF jump, well past the deadband) forces heat down to + -- 58degF first, landing cool at 60degF: still exactly a 2degF gap. + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 70, unit = "F" }))) + mock_device:emit_event( + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 70, unit = "F" })) + test.wait_for_events() + + for _, delay in ipairs({ 0.4, 1.1, 1.8, 2.5 }) do + test.timer.__create_and_queue_test_time_advance_timer(delay, "oneshot") + end + + local requested_celsius = utils.f_to_c(60) + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatCoolingSetpoint", command = "setCoolingSetpoint", + args = { requested_celsius } } + }) + test.wait_for_events() + + test.mock_time.advance_time(0.4) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Set({ + setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1, + scale = ThermostatSetpoint.scale.FAHRENHEIT, precision = 0, value = 58 + }))) + test.wait_for_events() + + test.mock_time.advance_time(0.7) -- 1.1 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Set({ + setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1, + scale = ThermostatSetpoint.scale.FAHRENHEIT, precision = 0, value = 60 + }))) + test.wait_for_events() + + test.mock_time.advance_time(0.7) -- 1.8 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1 }))) + test.wait_for_events() + + test.mock_time.advance_time(0.7) -- 2.5 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1 }))) + end +) + +test.register_coroutine_test( + "Heating setpoint request that would violate the deadband (degC wire scale) " .. + "should land on the converted ~1.1degC gap, not a raw 2.0 " .. + "(regression: deadband unit conversion)", + function() + mock_device:set_field( + constants.TEMPERATURE_SCALE, ThermostatSetpoint.scale.CELSIUS, { persist = true }) + + -- DEADBAND_F (2.0) is a degF *difference*; converting it to degC must use + -- the pure 5/9 ratio (~1.1degC), not treat 2.0 as if it were already a + -- degC value. Current cooling setpoint is 25.0degC; requesting 24.0degC + -- heat leaves only a 1.0degC gap, violating the ~1.1degC deadband, so the + -- driver must bump cooling up first. The bumped value pins down which + -- deadband was actually applied: 24.0 + 1.1 = 25.1degC (correct) vs. + -- 24.0 + 2.0 = 26.0degC (the pre-fix bug). + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" }))) + mock_device:emit_event( + capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 25.0, unit = "C" })) + test.wait_for_events() + + for _, delay in ipairs({ 0.4, 1.1, 1.8, 2.5 }) do + test.timer.__create_and_queue_test_time_advance_timer(delay, "oneshot") + end + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "thermostatHeatingSetpoint", command = "setHeatingSetpoint", args = { 24.0 } } + }) + test.wait_for_events() + + test.mock_time.advance_time(0.4) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Set({ + setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1, + scale = ThermostatSetpoint.scale.CELSIUS, precision = 1, value = 25.1, size = 2 + }))) + test.wait_for_events() + + test.mock_time.advance_time(0.7) -- 1.1 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Set({ + setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1, + scale = ThermostatSetpoint.scale.CELSIUS, precision = 1, value = 24.0, size = 2 + }))) + test.wait_for_events() + + test.mock_time.advance_time(0.7) -- 1.8 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1 }))) + test.wait_for_events() + + test.mock_time.advance_time(0.7) -- 2.5 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1 }))) + end +) + +---------------------------------------------------------------------- +-- refresh +---------------------------------------------------------------------- + +test.register_coroutine_test( + "Refresh capability command should re-poll all eight attributes on schedule", + function() + for _, delay in ipairs({ 0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0 }) do + test.timer.__create_and_queue_test_time_advance_timer(delay, "oneshot") + end + + test.socket.capability:__queue_receive({ + mock_device.id, + { capability = "refresh", command = "refresh", args = {} } + }) + -- The first step has a 0.0s delay, so its timer is ready immediately -- + -- the expectation must be registered before the first wait_for_events(). + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + SensorMultilevel:Get({ + sensor_type = SensorMultilevel.sensor_type.TEMPERATURE, + scale = SensorMultilevel.scale.FAHRENHEIT }))) + test.wait_for_events() + + test.mock_time.advance_time(1.0) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1 }))) + test.wait_for_events() + + test.mock_time.advance_time(1.0) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1 }))) + test.wait_for_events() + + test.mock_time.advance_time(1.0) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatMode:Get({}))) + test.wait_for_events() + + test.mock_time.advance_time(1.0) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatFanMode:Get({}))) + test.wait_for_events() + + test.mock_time.advance_time(1.0) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, Battery:Get({}))) + test.wait_for_events() + + test.mock_time.advance_time(1.0) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatOperatingState:Get({}))) + test.wait_for_events() + + test.mock_time.advance_time(1.0) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + SensorMultilevel:Get({ + sensor_type = SensorMultilevel.sensor_type.RELATIVE_HUMIDITY, + scale = SensorMultilevel.scale.relative_humidity.PERCENTAGE }))) + end +) + +---------------------------------------------------------------------- +-- doConfigure: lifeline association +---------------------------------------------------------------------- + +test.register_coroutine_test( + "doConfigure should stage a MultiChannelAssociation:Set then Get for the lifeline group", + function() + test.timer.__create_and_queue_test_time_advance_timer(1.5, "oneshot") + test.timer.__create_and_queue_test_time_advance_timer(2.5, "oneshot") + + test.socket.device_lifecycle():__queue_receive({ mock_device.id, "doConfigure" }) + mock_device:expect_metadata_update({ provisioning_state = "PROVISIONED" }) + test.wait_for_events() + + test.mock_time.advance_time(1.5) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + MultiChannelAssociation:Set({ + grouping_identifier = 1, + node_ids = {}, + multi_channel_nodes = { { node_id = 1, bit_address = false, end_point = 0 } } + }))) + test.wait_for_events() + + test.mock_time.advance_time(1.0) -- 2.5 + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + MultiChannelAssociation:Get({ grouping_identifier = 1 }))) + end +) + +---------------------------------------------------------------------- +-- Unsolicited Z-Wave reports reflected into capability state +---------------------------------------------------------------------- + +test.register_message_test( + "Unsolicited mode/operating-state/setpoint/temperature/battery reports " .. + "should update capability state", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command( + ThermostatMode:Report({ mode = ThermostatMode.mode.HEAT })) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message( + "main", capabilities.thermostatMode.thermostatMode("heat")) + }, + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command( + ThermostatOperatingState:Report( + { operating_state = ThermostatOperatingState.operating_state.AUX_HEATING })) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.thermostatOperatingState.thermostatOperatingState({ value = "heating" })) + }, + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command( + ThermostatSetpoint:Report({ + setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1, + scale = ThermostatSetpoint.scale.FAHRENHEIT, + precision = 0, + value = 72 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 72, unit = "F" })) + }, + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command( + SensorMultilevel:Report({ + sensor_type = SensorMultilevel.sensor_type.TEMPERATURE, + scale = 1, -- 1 = Fahrenheit per Z-Wave spec; the named scale_temperature + -- enum isn't exposed at SensorMultilevel.scale.FAHRENHEIT in + -- this lua_libs release (see report notes), so use the raw value. + sensor_value = 68 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.temperatureMeasurement.temperature({ value = 68, unit = "F" })) + }, + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command( + Battery:Report({ battery_level = 55 })) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", capabilities.battery.battery(55)) + }, + } +) + +test.register_message_test( + "Relative humidity reports should emit whole-percent humidity, clamped to 0-100", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command( + SensorMultilevel:Report({ + sensor_type = SensorMultilevel.sensor_type.RELATIVE_HUMIDITY, + scale = SensorMultilevel.scale.relative_humidity.PERCENTAGE, + sensor_value = 42.6 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.relativeHumidityMeasurement.humidity({ value = 43 })) + }, + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command( + SensorMultilevel:Report({ + sensor_type = SensorMultilevel.sensor_type.RELATIVE_HUMIDITY, + scale = SensorMultilevel.scale.relative_humidity.PERCENTAGE, + sensor_value = 101 + })) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.relativeHumidityMeasurement.humidity({ value = 100 })) + }, + } +) + +test.register_coroutine_test( + "A COOLING_1 setpoint report should schedule an extra HEATING_1 Get 2.5s later", + function() + test.timer.__create_and_queue_test_time_advance_timer(2.5, "oneshot") + + test.socket.zwave:__queue_receive({ + mock_device.id, + zw_test_utils.zwave_test_build_receive_command( + ThermostatSetpoint:Report({ + setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1, + scale = ThermostatSetpoint.scale.FAHRENHEIT, + precision = 0, + value = 75 + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 75, unit = "F" }))) + test.wait_for_events() + + test.mock_time.advance_time(2.5) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, + ThermostatSetpoint:Get({ setpoint_type = ThermostatSetpoint.setpoint_type.HEATING_1 }))) + end +) + +test.register_coroutine_test( + "A COOLING_1 setpoint report should NOT re-fire the HEATING_1 Get if the heat " .. + "setpoint is already known", + function() + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 70, unit = "F" }))) + mock_device:emit_event( + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 70, unit = "F" })) + test.wait_for_events() + + test.socket.zwave:__queue_receive({ + mock_device.id, + zw_test_utils.zwave_test_build_receive_command( + ThermostatSetpoint:Report({ + setpoint_type = ThermostatSetpoint.setpoint_type.COOLING_1, + scale = ThermostatSetpoint.scale.FAHRENHEIT, + precision = 0, + value = 75 + })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = 75, unit = "F" }))) + test.wait_for_events() + + -- No zwave send expected: the guard sees heatingSetpoint already known. + test.mock_time.advance_time(2.5) + test.wait_for_events() + end +) + +test.register_message_test( + "An unsolicited ThermostatFanMode:Report should update the thermostatFanMode capability", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, + zw_test_utils.zwave_test_build_receive_command( + ThermostatFanMode:Report({ fan_mode = ThermostatFanMode.fan_mode.AUTO_HIGH })) + } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.thermostatFanMode.thermostatFanMode("auto")) + }, + } +) + +---------------------------------------------------------------------- +-- Supported-mode / supported-fan-mode reports (boolean "Style A" fields, +-- confirmed as what the real ST25Z sends per the live dev-channel test) +---------------------------------------------------------------------- + +test.register_message_test( + "Supported thermostat mode / fan mode reports (boolean style) should populate " .. + "the supported-values lists", + { + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command( + ThermostatMode:SupportedReport({ + off = true, heat = true, cool = true, auto = true, auxiliary_emergency_heat = true })) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes( + { "off", "heat", "cool", "auto", "emergency heat" })) + }, + { + channel = "zwave", + direction = "receive", + message = { mock_device.id, zw_test_utils.zwave_test_build_receive_command( + ThermostatFanMode:SupportedReport({ auto_high = true, high = true, circulation = true })) } + }, + { + channel = "capability", + direction = "send", + message = mock_device:generate_test_message("main", + capabilities.thermostatFanMode.supportedThermostatFanModes({ "auto", "on", "circulate" })) + }, + } +) + +test.register_coroutine_test( + "Supported thermostat mode report should poll ThermostatMode:Get 1.2s later", + function() + test.timer.__create_and_queue_test_time_advance_timer(1.2, "oneshot") + + test.socket.zwave:__queue_receive({ + mock_device.id, + zw_test_utils.zwave_test_build_receive_command( + ThermostatMode:SupportedReport({ off = true, heat = true, cool = true, auto = true })) + }) + test.socket.capability:__expect_send( + mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes({ "off", "heat", "cool", "auto" }))) + test.wait_for_events() + + test.mock_time.advance_time(1.2) + test.socket.zwave:__expect_send( + zw_test_utils.zwave_test_build_send_command(mock_device, ThermostatMode:Get({}))) + end +) + +test.run_registered_tests() diff --git a/drivers/Copeland/sensi-st25z-zwave/src/thermostat-sensi-st25z/can_handle.lua b/drivers/Copeland/sensi-st25z-zwave/src/thermostat-sensi-st25z/can_handle.lua new file mode 100644 index 0000000000..05ec1b7783 --- /dev/null +++ b/drivers/Copeland/sensi-st25z-zwave/src/thermostat-sensi-st25z/can_handle.lua @@ -0,0 +1,14 @@ +-- Sensi Lite ST25Z, Copeland model 1F76U-22ZB +local function can_handle_thermostat_sensi_st25z(opts, driver, device, ...) + local MFR = 0x046B + local PROD = 0x0004 + local MODEL = 0x0002 + + if device:id_match(MFR, PROD, MODEL) then + return true, require "thermostat-sensi-st25z" + else + return false + end +end + +return can_handle_thermostat_sensi_st25z diff --git a/drivers/Copeland/sensi-st25z-zwave/src/thermostat-sensi-st25z/init.lua b/drivers/Copeland/sensi-st25z-zwave/src/thermostat-sensi-st25z/init.lua new file mode 100644 index 0000000000..2e8b193c25 --- /dev/null +++ b/drivers/Copeland/sensi-st25z-zwave/src/thermostat-sensi-st25z/init.lua @@ -0,0 +1,929 @@ +local capabilities = require "st.capabilities" +local log = require "log" +local cc = require "st.zwave.CommandClass" +local ThermostatMode = (require "st.zwave.CommandClass.ThermostatMode")({ version = 2 }) +local ThermostatSetpoint = (require "st.zwave.CommandClass.ThermostatSetpoint")({ version = 2 }) +local ThermostatFanMode = (require "st.zwave.CommandClass.ThermostatFanMode")({ version = 3 }) +local SensorMultilevel = (require "st.zwave.CommandClass.SensorMultilevel")({ version = 5 }) +local Association = (require "st.zwave.CommandClass.Association")({ version = 3 }) +local MultiChannelAssociation = + (require "st.zwave.CommandClass.MultiChannelAssociation")({ version = 3 }) +local Battery = (require "st.zwave.CommandClass.Battery")({ version = 1 }) +local ThermostatOperatingState = + (require "st.zwave.CommandClass.ThermostatOperatingState")({ version = 1 }) +local constants = require "st.zwave.constants" +local utils = require "st.utils" + +-- Short aliases: these setpoint_type values are referenced ~25 times below. +local HEATING_1 = ThermostatSetpoint.setpoint_type.HEATING_1 +local COOLING_1 = ThermostatSetpoint.setpoint_type.COOLING_1 + +---------------------------------------------------------------------- +-- Simple sequencer helpers (Option 1) +---------------------------------------------------------------------- + +local function send_after(device, delay_s, fn) + device.thread:call_with_delay(delay_s, function() + local ok, err = pcall(fn) + if not ok then + log.warn("[send_after] " .. tostring(err)) + end + end) +end + +local function request_sequence(device, steps, spacing_s, initial_delay_s) + local delay = initial_delay_s or 0.0 + local space = spacing_s or 0.7 + for _, step in ipairs(steps) do + send_after(device, delay, step) + delay = delay + space + end +end + +-- Safe enum extractors with fallbacks +local function tm_enums() + local m = ThermostatMode and ThermostatMode.mode + return { + OFF = m and m.OFF or 0x00, + HEAT = m and m.HEAT or 0x01, + COOL = m and m.COOL or 0x02, + AUTO = m and m.AUTO or 0x03, + AUXILIARY_HEAT = m and m.AUXILIARY_HEAT or 0x04, + EMERGENCY_HEAT = m and m.EMERGENCY_HEAT or 0x04, + } +end + +-- One-time normalized enums +local TM = tm_enums() + +---------------------------------------------------------------------- +-- Helpers / prefs +---------------------------------------------------------------------- + +-- Normalize Z-Wave mode to capability string +local function zw_mode_to_capability(val) + -- val can be number (0..), enum string ("HEAT"), or table-like + local m = ThermostatMode and ThermostatMode.mode + local num = (type(val) == "number" and val) or (m and m[val]) -- e.g. "HEAT" -> 1 + if num == (m and m.OFF) or num == 0 then return "off" end + if num == (m and m.HEAT) or num == 1 then return "heat" end + if num == (m and m.COOL) or num == 2 then return "cool" end + if num == (m and m.AUTO) or num == 3 then return "auto" end + if num == (m and m.AUXILIARY_HEAT) or num == (m and m.EMERGENCY_HEAT) or num == 11 then + return "emergency heat" + end + return nil +end + +local function thermostat_mode_report(_, device, cmd) + -- Examples seen: cmd.args.mode = "HEAT" (string) or 1 (number) + local cap = zw_mode_to_capability(cmd.args.mode) + if cap then + device:emit_event_for_endpoint( + cmd.src_channel, + capabilities.thermostatMode.thermostatMode(cap) + ) + end +end + +local function zw_fan_mode_to_capability(val) + local fm = ThermostatFanMode.fan_mode or {} + local num = (type(val) == "number" and val) or (fm[val]) + if num == nil then return nil end + if num == fm.AUTO_LOW or num == fm.AUTO_MEDIUM or num == fm.AUTO_HIGH then return "auto" end + if num == fm.LOW or num == fm.MEDIUM or num == fm.HIGH then return "on" end + if num == fm.CIRCULATION then return "circulate" end + return nil +end + +local function fan_mode_report(_, device, cmd) + -- Examples seen: cmd.args.fan_mode = "HIGH" (string) or a number + local cap = zw_fan_mode_to_capability(cmd.args.fan_mode) + if cap then + device:emit_event_for_endpoint( + cmd.src_channel, + capabilities.thermostatFanMode.thermostatFanMode(cap) + ) + end +end + +-- Room temperature and relative humidity (Sensor Multilevel v5) +local function sensor_multilevel_report(_, device, cmd) + local sensor_type = cmd.args.sensor_type + if sensor_type ~= SensorMultilevel.sensor_type.TEMPERATURE + and sensor_type ~= SensorMultilevel.sensor_type.RELATIVE_HUMIDITY then + return + end + + -- libs vary on the field name; check them in order + local val = cmd.args.sensor_value or cmd.args.scaled_value or cmd.args.value + if val == nil or val ~= val or val == math.huge or val == -math.huge then return end + + if sensor_type == SensorMultilevel.sensor_type.RELATIVE_HUMIDITY then + -- scale 0 = percentage; whole percent, matching the Zigbee driver + local pct = math.max(0, math.min(100, utils.round(val))) + device:emit_event(capabilities.relativeHumidityMeasurement.humidity({ value = pct })) + return + end + + -- scale: 0=C, 1=F + local is_f = (cmd.args.scale == SensorMultilevel.scale.FAHRENHEIT) or (cmd.args.scale == 1) + local unit = is_f and "F" or "C" + + -- remember wire scale for your setpoint logic + device:set_field( + constants.TEMPERATURE_SCALE, + is_f and ThermostatSetpoint.scale.FAHRENHEIT or ThermostatSetpoint.scale.CELSIUS, + { persist = true } + ) + + device:emit_event(capabilities.temperatureMeasurement.temperature({ value = val, unit = unit })) +end + +local function get_wire_scale(device) + -- Remembered from the latest Setpoint (or default to °F) + return device:get_field(constants.TEMPERATURE_SCALE) or ThermostatSetpoint.scale.FAHRENHEIT +end + +-- helpers to normalize rounding/precision by wire scale +local function round_int(x) return math.floor(x + 0.5) end + +local function normalize_out(v, wire_scale) + if wire_scale == ThermostatSetpoint.scale.FAHRENHEIT then + -- Whole °F only + return round_int(v), 0 -- precision=0 + else + -- Keep 0.5°C or 0.1°C as you prefer; sticking with 0.1 like your current code + local vv = math.floor(v * 10 + 0.5) / 10 + return vv, 1 -- precision=1 + end +end +-- Convert the unitless capability command number (usually °C) into the device's wire scale. +local function command_to_wire_value(cmd_val, wire_scale) + local is_c_cmd = (cmd_val < 40) -- ST often sends °C; heuristic + local v + if wire_scale == ThermostatSetpoint.scale.FAHRENHEIT then + v = is_c_cmd and utils.c_to_f(cmd_val) or cmd_val + v = round_int(v) -- whole °F here + else + v = is_c_cmd and cmd_val or utils.f_to_c(cmd_val) + v = math.floor(v * 10 + 0.5) / 10 -- keep 0.1°C like before + end + if v ~= v or v == math.huge or v == -math.huge then return nil end + return v +end + +-- ThermostatSetpoint:Set auto-sizes its wire field from the *unscaled* value, +-- before our explicit precision scales it up -- a whole-number degC setpoint +-- (e.g. 13.0) auto-sizes to 1 byte, then precision=1 scales it to 130 and +-- overflows that byte during serialization. Force 2 bytes for degC, which +-- comfortably covers the full range regardless of whether the value is a +-- whole number. degF never hits this (max value 99, precision 0 always fits). +local function setpoint_size_for(wire_scale) + return (wire_scale == ThermostatSetpoint.scale.CELSIUS) and 2 or nil +end + +-- Temperature Poll Helper +local function poll_room_temp(device) + if not device:is_cc_supported(cc.SENSOR_MULTILEVEL) then return end + + -- Use your remembered wire scale (defaults to °F in your code); returns + -- ThermostatSetpoint.scale.FAHRENHEIT or .CELSIUS + local wire_scale = get_wire_scale(device) + + local sm_scale = + (wire_scale == ThermostatSetpoint.scale.FAHRENHEIT) + and SensorMultilevel.scale.FAHRENHEIT + or SensorMultilevel.scale.CELSIUS + + device:send(SensorMultilevel:Get({ + sensor_type = SensorMultilevel.sensor_type.TEMPERATURE, + scale = sm_scale + })) +end + +-- Humidity Poll Helper +local function poll_humidity(device) + if not device:is_cc_supported(cc.SENSOR_MULTILEVEL) then return end + device:send(SensorMultilevel:Get({ + sensor_type = SensorMultilevel.sensor_type.RELATIVE_HUMIDITY, + scale = SensorMultilevel.scale.relative_humidity.PERCENTAGE + })) +end + +-- Read the *other* setpoint (cool when setting heat, or heat when setting cool) +-- and express it in the same wire scale for deadband checks. +local function latest_other_in_wire(device, attr_capability, attr_name, wire_scale) + local raw = device:get_latest_state("main", attr_capability.ID, attr_name) + if raw == nil then return nil end + if wire_scale == ThermostatSetpoint.scale.FAHRENHEIT then + return (raw < 40) and utils.c_to_f(raw) or raw + else + return (raw >= 40) and utils.f_to_c(raw) or raw + end +end + +local function clamp_and_round(value, min_v, max_v, precision) + local v = math.min(math.max(value, min_v), max_v) + if precision then + local p = 10 ^ precision + v = math.floor(v * p + 0.5) / p + end + return v +end + +-- Absolute limits expressed in the device's active wire scale +local function abs_limits_in_wire(wire_scale) + if wire_scale == ThermostatSetpoint.scale.FAHRENHEIT then + return 45.0, 99.0 -- heat_abs_min, cool_abs_max + else + return utils.f_to_c(45.0), utils.f_to_c(99.0) + end +end + +local HEAT_MIN_F, HEAT_MAX_F = 45.0, 97.0 +local COOL_MIN_F, COOL_MAX_F = 47.0, 99.0 +local DEADBAND_F = 2.0 + +-- DEADBAND_F is a *temperature difference*, not an absolute value -- converting +-- it to degC must use the pure 5/9 ratio, not utils.f_to_c's affine (subtracts +-- 32 first) conversion, which is only valid for absolute temperatures. Matches +-- the Zigbee driver's DEADBAND_C = 1.1. +local function deadband_in(wire_scale) + if wire_scale == ThermostatSetpoint.scale.FAHRENHEIT then + return DEADBAND_F + else + return DEADBAND_F / 1.8 + end +end + +---------------------------------------------------------------------- +-- Lifecycle +---------------------------------------------------------------------- + +local function device_init(driver, device) + -- Force device scale to °F (persist). Reports can overwrite later if needed. + device:set_field( + constants.TEMPERATURE_SCALE, ThermostatSetpoint.scale.FAHRENHEIT, { persist = true }) + + -- First pass: stagger Supported* and current values + request_sequence(device, { + function() + if device:is_cc_supported(cc.THERMOSTAT_MODE) then + device:send(ThermostatMode:SupportedGet({})) + end + end, + function() + if device:is_cc_supported(cc.THERMOSTAT_FAN_MODE) then + device:send(ThermostatFanMode:SupportedGet({})) + end + end, + function() + if device:is_cc_supported(cc.THERMOSTAT_SETPOINT) then + device:send(ThermostatSetpoint:Get({ setpoint_type = HEATING_1 })) + end + end, + function() + if device:is_cc_supported(cc.THERMOSTAT_SETPOINT) then + device:send(ThermostatSetpoint:Get({ setpoint_type = COOLING_1 })) + end + end, + function() + if device:is_cc_supported(cc.THERMOSTAT_MODE) then + device:send(ThermostatMode:Get({})) + end + end, + function() + if device:is_cc_supported(cc.THERMOSTAT_FAN_MODE) then + device:send(ThermostatFanMode:Get({})) + end + end, + function() + if device:is_cc_supported(cc.SENSOR_MULTILEVEL) then + device:send(SensorMultilevel:Get({ + sensor_type = SensorMultilevel.sensor_type.TEMPERATURE, + -- scale is advisory; many stats reply in their native scale anyway + scale = SensorMultilevel.scale.CELSIUS + })) + end + end, + function() + if device:is_cc_supported(cc.THERMOSTAT_OPERATING_STATE) then + device:send(ThermostatOperatingState:Get({})) + end + end, + function() + poll_humidity(device) + end, + }, 1.1, 1.2) + + -- Single late retry for mode only — guarded so it only fires if mode is still unknown + device.thread:call_with_delay(15.0, function() + local cur = device:get_latest_state("main", capabilities.thermostatMode.ID, "thermostatMode") + if not cur and device:is_cc_supported(cc.THERMOSTAT_MODE) then + device:send(ThermostatMode:Get({})) + end + end) + + -- Late guards: retry heating setpoint and temperature if still missing after + -- radio has fully settled. Handles the case where initial GETs failed and + -- the device didn't send unsolicited reports for these during join. + device.thread:call_with_delay(12.0, function() + local cur = device:get_latest_state( + "main", capabilities.thermostatHeatingSetpoint.ID, "heatingSetpoint") + if not cur and device:is_cc_supported(cc.THERMOSTAT_SETPOINT) then + device:send(ThermostatSetpoint:Get({ setpoint_type = HEATING_1 })) + end + end) + + device.thread:call_with_delay(14.0, function() + local cur = device:get_latest_state( + "main", capabilities.thermostatCoolingSetpoint.ID, "coolingSetpoint") + if not cur and device:is_cc_supported(cc.THERMOSTAT_SETPOINT) then + device:send(ThermostatSetpoint:Get({ setpoint_type = COOLING_1 })) + end + end) + + device.thread:call_with_delay(16.0, function() + local cur = device:get_latest_state( + "main", capabilities.thermostatOperatingState.ID, "thermostatOperatingState") + if not cur and device:is_cc_supported(cc.THERMOSTAT_OPERATING_STATE) then + device:send(ThermostatOperatingState:Get({})) + end + end) + + device.thread:call_with_delay(17.0, function() + local cur = device:get_latest_state( + "main", capabilities.thermostatFanMode.ID, "thermostatFanMode") + if not cur and device:is_cc_supported(cc.THERMOSTAT_FAN_MODE) then + device:send(ThermostatFanMode:Get({})) + end + end) + + device.thread:call_with_delay(13.0, function() + local cur = device:get_latest_state( + "main", capabilities.temperatureMeasurement.ID, "temperature") + if not cur then + poll_room_temp(device) + end + end) + + device.thread:call_with_delay(18.0, function() + local cur = device:get_latest_state( + "main", capabilities.relativeHumidityMeasurement.ID, "humidity") + if not cur then + poll_humidity(device) + end + end) + + -- An issue was observed where changing fan mode from the thermostat wasn't getting updated on the SmartThings app + -- Turned out FAN MODE report was not getting sent, but just the THERMOSTAT STATE which isn't enough for the app + -- to determine status of fan mode + -- This requires fix at the thermostat firmware side, which will be reported to them separately. + -- until then a work-around is being put here where fan mode report will be sent out periodically + -- + device.thread:call_on_schedule(20, function() + if device:is_cc_supported(cc.THERMOSTAT_FAN_MODE) then + device:send(ThermostatFanMode:Get({})) + end + end, "fan_mode_poll") +end + +local function device_added(driver, device) + -- Seed operating state so the provisioning UI doesn't hang waiting for it. + -- The real value arrives once the radio settles and the GET succeeds. + device:emit_event( + capabilities.thermostatOperatingState.thermostatOperatingState({ value = "idle" })) +end + +local function do_configure(driver, device) + local hub_node = + device.parent_assigned_node_id or device.driver.environment_info.hub_zwave_id or 1 + -- Lifeline association (group 1) — stagger to avoid right-after-join hammer. + -- Prefer MultiChannelAssociation (which this device supports) so the hub's + -- endpoint 0 is explicitly included; plain Association:Set was leaving node_ids={}. + request_sequence(device, { + function() + if device:is_cc_supported(cc.MULTI_CHANNEL_ASSOCIATION) then + device:send(MultiChannelAssociation:Set({ + grouping_identifier = 1, + node_ids = {}, + multi_channel_nodes = { { node_id = hub_node, bit_address = false, end_point = 0 } } + })) + elseif device:is_cc_supported(cc.ASSOCIATION) then + device:send(Association:Set({ grouping_identifier = 1, node_ids = { hub_node } })) + end + end, + function() + if device:is_cc_supported(cc.MULTI_CHANNEL_ASSOCIATION) then + device:send(MultiChannelAssociation:Get({ grouping_identifier = 1 })) + elseif device:is_cc_supported(cc.ASSOCIATION) then + device:send(Association:Get({ grouping_identifier = 1 })) + end + end, + }, 1.0, 1.5) +end + +---------------------------------------------------------------------- +-- Capability handlers (sequenced follow-ups) +---------------------------------------------------------------------- + +local MODE_MAP = { + ["off"] = TM.OFF, + ["heat"] = TM.HEAT, + ["cool"] = TM.COOL, + ["auto"] = TM.AUTO, + ["emergency heat"] = TM.EMERGENCY_HEAT, -- will exist even if .mode is missing +} + +local function set_mode(driver, device, cmd) + local tstat_mode = MODE_MAP[cmd.args.mode] + if not tstat_mode then return end + device:send_to_component(ThermostatMode:Set({ mode = tstat_mode }), cmd.component) + + request_sequence(device, { + function() device:send_to_component(ThermostatMode:Get({}), cmd.component) end + }, 0.7, 0.9) +end + +-- choose enums the device actually supports +local function _pick_auto_enum() + local fm = ThermostatFanMode.fan_mode or {} + return fm.AUTO_HIGH or fm.AUTO_LOW -- prefer AUTO_HIGH if available +end + +local function _pick_on_enum() + local fm = ThermostatFanMode.fan_mode or {} + return fm.HIGH or fm.LOW -- prefer HIGH if available +end + +local FAN_MAP = { + ["auto"] = _pick_auto_enum(), + ["on"] = _pick_on_enum(), + ["circulate"] = (ThermostatFanMode.fan_mode and ThermostatFanMode.fan_mode.CIRCULATION) or 0x06, +} + +local function set_fan_mode(driver, device, cmd) + local requested = cmd.args.mode or cmd.args.fanMode + if not requested then return end + + -- normalize just in case + requested = string.lower(tostring(requested)) + + local fan_mode = FAN_MAP[requested] + if not fan_mode then return end + + device:send_to_component(ThermostatFanMode:Set({ fan_mode = fan_mode }), cmd.component) + -- verify + device.thread:call_with_delay(5.0, function() + device:send_to_component(ThermostatFanMode:Get({}), cmd.component) + end) +end + +-- thermostatFanMode's fanAuto/fanOn/fanCirculate are separate commands (not just +-- setThermostatFanMode with an arg), each needs its own handler registered below +local function fan_auto(driver, device, cmd) + set_fan_mode(driver, device, { args = { mode = "auto" }, component = cmd.component }) +end + +local function fan_on(driver, device, cmd) + set_fan_mode(driver, device, { args = { mode = "on" }, component = cmd.component }) +end + +local function fan_circulate(driver, device, cmd) + set_fan_mode(driver, device, { args = { mode = "circulate" }, component = cmd.component }) +end + +-- === Heating setpoint (honors wire scale) === +local function set_heating_setpoint(driver, device, cmd) + local wire_scale = get_wire_scale(device) + + local requested = command_to_wire_value(cmd.args.setpoint, wire_scale) + if not requested then return end + + -- Device caps (keep your existing limits) + local heat_min, heat_max, cool_min, cool_max + if wire_scale == ThermostatSetpoint.scale.FAHRENHEIT then + heat_min, heat_max = math.max(HEAT_MIN_F, 45.0), HEAT_MAX_F + cool_min, cool_max = COOL_MIN_F, math.min(COOL_MAX_F, 99.0) + else + heat_min, heat_max = utils.f_to_c(math.max(HEAT_MIN_F, 45.0)), utils.f_to_c(HEAT_MAX_F) + cool_min, cool_max = utils.f_to_c(COOL_MIN_F), utils.f_to_c(math.min(COOL_MAX_F, 99.0)) + end + + -- Clamp only; final rounding/precision chosen by normalize_out + local tgt_heat = clamp_and_round(requested, heat_min, heat_max, nil) + + -- Current COOL (same wire scale) + local cur_cool = latest_other_in_wire( + device, + capabilities.thermostatCoolingSetpoint, + capabilities.thermostatCoolingSetpoint.coolingSetpoint.NAME, + wire_scale + ) + + local db = deadband_in(wire_scale) + local violates = (cur_cool ~= nil) and (tgt_heat > (cur_cool - db)) + + if not violates then + -- No deadband issue → set HEAT only + local val_heat, prec_heat = normalize_out(tgt_heat, wire_scale) + device:send(ThermostatSetpoint:Set({ + setpoint_type = HEATING_1, + scale = wire_scale, + precision = prec_heat, + value = val_heat, + size = setpoint_size_for(wire_scale) + })) + request_sequence(device, { + function() device:send(ThermostatSetpoint:Get({ setpoint_type = HEATING_1 })) end + }, 0.7, 1.0) + return + end + + -- Would violate → compute required COOL and check absolute limit + local _, cool_abs_max = abs_limits_in_wire(wire_scale) + local needed_cool = tgt_heat + db + if needed_cool > cool_abs_max then + log.warn(string.format("[HEAT set %.1f] Rejecting: COOL would need to be %.1f > abs max %.1f", + tgt_heat, needed_cool, cool_abs_max)) + request_sequence(device, { + function() device:send(ThermostatSetpoint:Get({ setpoint_type = HEATING_1 })) end, + function() device:send(ThermostatSetpoint:Get({ setpoint_type = COOLING_1 })) end, + }, 0.6, 0.2) + return + end + + local tgt_cool = clamp_and_round(needed_cool, cool_min, cool_max, nil) + + -- Order: COOL first, then HEAT + local val_cool, prec_cool = normalize_out(tgt_cool, wire_scale) + local val_heat2, prec_heat2 = normalize_out(tgt_heat, wire_scale) + request_sequence(device, { + function() device:send(ThermostatSetpoint:Set({ + setpoint_type = COOLING_1, + scale = wire_scale, + precision = prec_cool, + value = val_cool, + size = setpoint_size_for(wire_scale) + })) end, + function() device:send(ThermostatSetpoint:Set({ + setpoint_type = HEATING_1, + scale = wire_scale, + precision = prec_heat2, + value = val_heat2, + size = setpoint_size_for(wire_scale) + })) end, + function() device:send(ThermostatSetpoint:Get({ setpoint_type = COOLING_1 })) end, + function() device:send(ThermostatSetpoint:Get({ setpoint_type = HEATING_1 })) end, + }, 0.7, 0.4) +end + +-- === Cooling setpoint (honors wire scale) === +local function set_cooling_setpoint(driver, device, cmd) + local wire_scale = get_wire_scale(device) + + local requested = command_to_wire_value(cmd.args.setpoint, wire_scale) + if not requested then return end + + -- Device caps + local heat_min, heat_max, cool_min, cool_max + if wire_scale == ThermostatSetpoint.scale.FAHRENHEIT then + heat_min, heat_max = math.max(HEAT_MIN_F, 45.0), HEAT_MAX_F + cool_min, cool_max = COOL_MIN_F, math.min(COOL_MAX_F, 99.0) + else + heat_min, heat_max = utils.f_to_c(math.max(HEAT_MIN_F, 45.0)), utils.f_to_c(HEAT_MAX_F) + cool_min, cool_max = utils.f_to_c(COOL_MIN_F), utils.f_to_c(math.min(COOL_MAX_F, 99.0)) + end + + local tgt_cool = clamp_and_round(requested, cool_min, cool_max, nil) + + -- Current HEAT (same wire scale) + local cur_heat = latest_other_in_wire( + device, + capabilities.thermostatHeatingSetpoint, + capabilities.thermostatHeatingSetpoint.heatingSetpoint.NAME, + wire_scale + ) + + local db = deadband_in(wire_scale) + local violates = (cur_heat ~= nil) and (tgt_cool < (cur_heat + db)) + + if not violates then + -- No deadband issue → set COOL only + local val_cool, prec_cool = normalize_out(tgt_cool, wire_scale) + device:send(ThermostatSetpoint:Set({ + setpoint_type = COOLING_1, + scale = wire_scale, + precision = prec_cool, + value = val_cool, + size = setpoint_size_for(wire_scale) + })) + request_sequence(device, { + function() device:send(ThermostatSetpoint:Get({ setpoint_type = COOLING_1 })) end + }, 0.7, 1.0) + return + end + + -- Would violate → compute required HEAT and check absolute limit + local heat_abs_min, _ = abs_limits_in_wire(wire_scale) + local needed_heat = tgt_cool - db + if needed_heat < heat_abs_min then + log.warn(string.format("[COOL set %.1f] Rejecting: HEAT would need to be %.1f < abs min %.1f", + tgt_cool, needed_heat, heat_abs_min)) + request_sequence(device, { + function() device:send(ThermostatSetpoint:Get({ setpoint_type = COOLING_1 })) end, + function() device:send(ThermostatSetpoint:Get({ setpoint_type = HEATING_1 })) end, + }, 0.6, 0.2) + return + end + + local tgt_heat = clamp_and_round(needed_heat, heat_min, heat_max, nil) + + -- Order: HEAT first, then COOL + local val_heat, prec_heat = normalize_out(tgt_heat, wire_scale) + local val_cool2, prec_cool2 = normalize_out(tgt_cool, wire_scale) + request_sequence(device, { + function() device:send(ThermostatSetpoint:Set({ + setpoint_type = HEATING_1, + scale = wire_scale, + precision = prec_heat, + value = val_heat, + size = setpoint_size_for(wire_scale) + })) end, + function() device:send(ThermostatSetpoint:Set({ + setpoint_type = COOLING_1, + scale = wire_scale, + precision = prec_cool2, + value = val_cool2, + size = setpoint_size_for(wire_scale) + })) end, + function() device:send(ThermostatSetpoint:Get({ setpoint_type = HEATING_1 })) end, + function() device:send(ThermostatSetpoint:Get({ setpoint_type = COOLING_1 })) end, + }, 0.7, 0.4) +end + +local OPERATING_STATE_MAP = { + -- string enum form (some SDK versions) + IDLE = "idle", + HEATING = "heating", + COOLING = "cooling", + FAN_ONLY = "fan only", + PENDING_HEAT = "pending heat", + PENDING_COOL = "pending cool", + VENT_ECONOMIZER = "vent economizer", + AUX_HEATING = "heating", + SECOND_STAGE_HEATING = "heating", + SECOND_STAGE_COOLING = "cooling", + SECOND_STAGE_AUX_HEAT = "heating", + AUX_PLUS_SECOND_STAGE_HEAT = "heating", + -- numeric form (other SDK versions) + [0] = "idle", + [1] = "heating", + [2] = "cooling", + [3] = "fan only", + [4] = "pending heat", + [5] = "pending cool", + [6] = "vent economizer", + [7] = "heating", -- AUX_HEATING + [8] = "heating", -- 2ND_STAGE_HEATING (heat pump second stage / aux) + [9] = "cooling", -- 2ND_STAGE_COOLING + [10] = "heating", -- 2ND_STAGE_AUX_HEAT + [11] = "heating", -- AUX_PLUS_2ND_STAGE_HEAT +} + +local function thermostat_operating_state_report(_, device, cmd) + local state = OPERATING_STATE_MAP[cmd.args.operating_state] + log.debug(string.format( + "[OperatingState] raw=%s mapped=%s", tostring(cmd.args.operating_state), tostring(state))) + if state then + device:emit_event_for_endpoint( + cmd.src_channel, + capabilities.thermostatOperatingState.thermostatOperatingState({ value = state }) + ) + end +end + +local function refresh_all(self, device) + request_sequence(device, { + function() + poll_room_temp(device) + end, + function() + device:send(ThermostatSetpoint:Get({ setpoint_type = HEATING_1 })) + end, + function() + device:send(ThermostatSetpoint:Get({ setpoint_type = COOLING_1 })) + end, + function() + device:send(ThermostatMode:Get({})) + end, + function() + device:send(ThermostatFanMode:Get({})) + end, + function() + device:send(Battery:Get({})) + end, + function() + if device:is_cc_supported(cc.THERMOSTAT_OPERATING_STATE) then + device:send(ThermostatOperatingState:Get({})) + end + end, + function() + poll_humidity(device) + end, + }, 1.0, 0.0) +end + +---------------------------------------------------------------------- +-- Z-Wave report handlers +---------------------------------------------------------------------- + +local function thermostat_setpoint_report(_, device, cmd) + -- Normalize scale: can be number (0/1) or string ("CELSIUS"/"FAHRENHEIT") + local s = cmd.args.scale + local is_f = (s == ThermostatSetpoint.scale.FAHRENHEIT) or (s == "FAHRENHEIT") or (s == 1) + local unit = is_f and "F" or "C" + + -- Remember whatever the device told us (persist) + device:set_field( + constants.TEMPERATURE_SCALE, + is_f and ThermostatSetpoint.scale.FAHRENHEIT or ThermostatSetpoint.scale.CELSIUS, + { persist = true } + ) + + -- Round before emitting so app stays in integer mode for °F + local raw = cmd.args.value + local out_val + if unit == "F" then + out_val = round_int(raw) -- whole °F + else + out_val = math.floor(raw * 10 + 0.5) / 10 -- 0.1 °C + end + + if cmd.args.setpoint_type == HEATING_1 then + device:emit_event_for_endpoint( + cmd.src_channel, + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = out_val, unit = unit }) + ) + elseif cmd.args.setpoint_type == COOLING_1 then + device:emit_event_for_endpoint( + cmd.src_channel, + capabilities.thermostatCoolingSetpoint.coolingSetpoint({ value = out_val, unit = unit }) + ) + -- Retry HEAT once the radio quiets down, but only if we still don't have it. + device.thread:call_with_delay(2.5, function() + local cur_heat = device:get_latest_state( + "main", capabilities.thermostatHeatingSetpoint.ID, "heatingSetpoint") + if not cur_heat and device:is_cc_supported(cc.THERMOSTAT_SETPOINT) then + device:send(ThermostatSetpoint:Get({ setpoint_type = HEATING_1 })) + end + end) + end +end + +local function thermostat_mode_supported_report(_, device, cmd) + local a = cmd.args or {} + local supported = {} + + -- Style A: boolean fields (what your logs show) + if type(a.off) == "boolean" then + if a.off then table.insert(supported, "off") end + if a.heat then table.insert(supported, "heat") end + if a.cool then table.insert(supported, "cool") end + if a.auto then table.insert(supported, "auto") end + -- Aux/emergency shows up as "auxiliary_emergency_heat" on some stacks + if a.auxiliary_emergency_heat or a.emergency_heat then + table.insert(supported, "emergency heat") + end + else + -- Style B: numeric bitmask (older libs) + local map = { + OFF = "off", + HEAT = "heat", + COOL = "cool", + AUTO = "auto", + AUXILIARY_HEAT = "emergency heat", + EMERGENCY_HEAT = "emergency heat", + } + local sm = a.supported_modes or {} + for k, name in pairs(map) do + -- k could be enum name or numeric; accept both + local bit = (ThermostatMode.mode and ThermostatMode.mode[k]) or k + if sm[bit] then table.insert(supported, name) end + end + end + + if #supported > 0 then + device:emit_event(capabilities.thermostatMode.supportedThermostatModes(supported)) + end + + device.thread:call_with_delay(1.2, function() + if device:is_cc_supported(cc.THERMOSTAT_MODE) then + device:send(ThermostatMode:Get({})) + end + end) +end + +local function fan_mode_supported_report(_, device, cmd) + local a = cmd.args or {} + local supported = {} + + -- Style A: boolean fields (your logs: auto_high=true, high=true, ...) + if type(a.auto_high) == "boolean" or type(a.auto_low) == "boolean" then + -- Any AUTO_* counts as "auto" + if a.auto_low or a.auto_medium or a.auto_high then + table.insert(supported, "auto") + end + -- Any *_SPEED or "on"/"on_high" counts as "on" + if a.low or a.medium or a.high or a.on or a.on_high then + table.insert(supported, "on") + end + -- Circulation + if a.circulation then + table.insert(supported, "circulate") + end + else + -- Style B: numeric bitmask + local sm = a.supported or {} + local tfm = ThermostatFanMode.fan_mode or {} + local auto_any = sm[tfm.AUTO_LOW] or sm[tfm.AUTO_MEDIUM] or sm[tfm.AUTO_HIGH] + local on_any = sm[tfm.LOW] or sm[tfm.MEDIUM] or sm[tfm.HIGH] + local circ = sm[tfm.CIRCULATION] + + if auto_any then table.insert(supported, "auto") end + if on_any then table.insert(supported, "on") end + if circ then table.insert(supported, "circulate") end + end + + if #supported > 0 then + device:emit_event(capabilities.thermostatFanMode.supportedThermostatFanModes(supported)) + end +end + +local function battery_report(_, device, cmd) + local pct = math.max(0, math.min(100, cmd.args.battery_level or 0)) + device:emit_event(capabilities.battery.battery(pct)) +end + +---------------------------------------------------------------------- +-- Sub-driver table +---------------------------------------------------------------------- + +return { + NAME = "thermostat-sensi-st25z", + + lifecycle_handlers = { + init = device_init, + added = device_added, + doConfigure = do_configure, + }, + + capability_handlers = { + [capabilities.thermostatHeatingSetpoint.ID] = { + [capabilities.thermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = + set_heating_setpoint + }, + [capabilities.thermostatCoolingSetpoint.ID] = { + [capabilities.thermostatCoolingSetpoint.commands.setCoolingSetpoint.NAME] = + set_cooling_setpoint + }, + [capabilities.thermostatMode.ID] = { + [capabilities.thermostatMode.commands.setThermostatMode.NAME] = set_mode + }, + [capabilities.thermostatFanMode.ID] = { + [capabilities.thermostatFanMode.commands.setThermostatFanMode.NAME] = set_fan_mode, + [capabilities.thermostatFanMode.commands.fanAuto.NAME] = fan_auto, + [capabilities.thermostatFanMode.commands.fanOn.NAME] = fan_on, + [capabilities.thermostatFanMode.commands.fanCirculate.NAME] = fan_circulate + }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = refresh_all + } + }, + + zwave_handlers = { + [cc.THERMOSTAT_SETPOINT] = { + [ThermostatSetpoint.REPORT] = thermostat_setpoint_report, + }, + [cc.THERMOSTAT_MODE] = { + [ThermostatMode.SUPPORTED_REPORT] = thermostat_mode_supported_report, + [ThermostatMode.REPORT] = thermostat_mode_report, + }, + [cc.THERMOSTAT_FAN_MODE] = { + [ThermostatFanMode.SUPPORTED_REPORT] = fan_mode_supported_report, + [ThermostatFanMode.REPORT] = fan_mode_report, + }, + [cc.BATTERY] = { + [Battery.REPORT] = battery_report, + }, + [cc.SENSOR_MULTILEVEL] = { + [SensorMultilevel.REPORT] = sensor_multilevel_report, + }, + [cc.THERMOSTAT_OPERATING_STATE] = { + [ThermostatOperatingState.REPORT] = thermostat_operating_state_report, + }, + }, + + can_handle = require("thermostat-sensi-st25z.can_handle") +}