From 818080bfe41d2551957993ea5d00b9fdd70d45c1 Mon Sep 17 00:00:00 2001 From: seojune Date: Mon, 4 May 2026 09:44:42 +0900 Subject: [PATCH 1/2] add Aqara Bath Heater T1 --- drivers/Aqara/aqara-bath-heater/config.yml | 6 + .../Aqara/aqara-bath-heater/fingerprints.yml | 6 + .../profiles/aqara-bath-heater.yml | 168 +++ .../aqara-bath-heater/src/aqara_cluster.lua | 14 + drivers/Aqara/aqara-bath-heater/src/init.lua | 592 +++++++++ .../src/test/test_aqara_bath_heater.lua | 1112 +++++++++++++++++ 6 files changed, 1898 insertions(+) create mode 100644 drivers/Aqara/aqara-bath-heater/config.yml create mode 100644 drivers/Aqara/aqara-bath-heater/fingerprints.yml create mode 100644 drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml create mode 100644 drivers/Aqara/aqara-bath-heater/src/aqara_cluster.lua create mode 100644 drivers/Aqara/aqara-bath-heater/src/init.lua create mode 100644 drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua diff --git a/drivers/Aqara/aqara-bath-heater/config.yml b/drivers/Aqara/aqara-bath-heater/config.yml new file mode 100644 index 0000000000..4465c61440 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/config.yml @@ -0,0 +1,6 @@ +name: Aqara Bath Heater +packageKey: aqara-bath-heater +permissions: + zigbee: {} +description: "SmartThings driver for Aqara Bath Heater devices" +vendorSupportInformation: "https://www.aqara.com/en/support/" diff --git a/drivers/Aqara/aqara-bath-heater/fingerprints.yml b/drivers/Aqara/aqara-bath-heater/fingerprints.yml new file mode 100644 index 0000000000..ecef588f02 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/fingerprints.yml @@ -0,0 +1,6 @@ +zigbeeManufacturer: + - id: "Aqara/lumi.bhf_light.acn001" + deviceLabel: Aqara Bath Heater T1 + manufacturer: Aqara + model: lumi.bhf_light.acn001 + deviceProfileName: "aqara-bath-heater" diff --git a/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml b/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml new file mode 100644 index 0000000000..0002dae8f9 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml @@ -0,0 +1,168 @@ +name: aqara-bath-heater +components: + - id: main + capabilities: + - id: switch + - id: switchLevel + - id: colorTemperature + config: + values: + - key: "colorTemperature.value" + range: [2700, 6500] + - id: thermostatMode + config: + values: + - key: "thermostatMode.value" + enabledValues: + - "off" + - "heat" + - "dryair" + - "cool" + - "fanonly" + - id: thermostatHeatingSetpoint + config: + values: + - key: "thermostatHeatingSetpoint.value" + range: [16, 45] + unit: "C" + - id: fanOscillationMode + config: + values: + - key: "fanOscillationMode.value" + enabledValues: + - "swing" + - "fixed" + - id: fanMode + config: + values: + - key: "fanMode.value" + enabledValues: + - "low" + - "medium" + - "high" + categories: + - name: Thermostat +deviceConfig: + dashboard: + states: + - component: main + capability: switch + version: 1 + - component: main + capability: fanMode + version: 1 + actions: + - component: main + capability: switch + version: 1 + detailView: + - component: main + capability: switch + version: 1 + - component: main + capability: switchLevel + version: 1 + - component: main + capability: colorTemperature + version: 1 + - component: main + capability: thermostatMode + version: 1 + - component: main + capability: thermostatHeatingSetpoint + version: 1 + visibleCondition: + component: main + capability: thermostatMode + version: 1 + value: thermostatMode.value + operator: EQUALS + operand: "heat" + - component: main + capability: fanOscillationMode + version: 1 + visibleCondition: + component: main + capability: thermostatMode + version: 1 + value: thermostatMode.value + operator: ONE_OF + operand: '["heat", "dryair", "cool"]' + - component: main + capability: fanMode + version: 1 + visibleCondition: + component: main + capability: thermostatMode + version: 1 + value: thermostatMode.value + operator: ONE_OF + operand: '["heat", "dryair", "cool", "fanonly"]' + automation: + conditions: + - component: main + capability: switch + version: 1 + - component: main + capability: switchLevel + version: 1 + - component: main + capability: colorTemperature + version: 1 + - component: main + capability: thermostatMode + version: 1 + - component: main + capability: thermostatHeatingSetpoint + version: 1 + - component: main + capability: fanOscillationMode + version: 1 + - component: main + capability: fanMode + version: 1 + values: + - key: "fanMode.value" + enabledValues: + - "low" + - "medium" + - "high" + actions: + - component: main + capability: switch + version: 1 + - component: main + capability: switchLevel + version: 1 + - component: main + capability: colorTemperature + version: 1 + - component: main + capability: thermostatMode + version: 1 + - component: main + capability: thermostatHeatingSetpoint + version: 1 + - component: main + capability: fanOscillationMode + version: 1 + - component: main + capability: fanMode + version: 1 + values: + - key: "setFanMode.fanMode" + enabledValues: + - "low" + - "medium" + - "high" +preferences: + - preferenceId: stse.nightLightMode + explicit: true + - preferenceId: stse.nightLightStartTime + explicit: true + - preferenceId: stse.nightLightEndTime + explicit: true + - preferenceId: stse.muteBeep + explicit: true + - preferenceId: stse.thermostatCtrl + explicit: true diff --git a/drivers/Aqara/aqara-bath-heater/src/aqara_cluster.lua b/drivers/Aqara/aqara-bath-heater/src/aqara_cluster.lua new file mode 100644 index 0000000000..90fd6d0655 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/src/aqara_cluster.lua @@ -0,0 +1,14 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local M = {} + +M.CLUSTER_ID = 0xFCC0 +M.MFG_CODE = 0x115F -- Lumi/Aqara manufacturer code + +M.ATTR_AC_CODE = 0x024F +M.ATTR_THERMOSTAT_CTRL_SW = 0x02BE +M.ATTR_DND_BEEP = 0x0256 +M.ATTR_DND_TIME = 0x0257 +M.ATTR_NIGHT_LIGHT = 0x0518 + +return M diff --git a/drivers/Aqara/aqara-bath-heater/src/init.lua b/drivers/Aqara/aqara-bath-heater/src/init.lua new file mode 100644 index 0000000000..78e377e0e1 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/src/init.lua @@ -0,0 +1,592 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +local capabilities = require "st.capabilities" +local ZigbeeDriver = require "st.zigbee" +local cluster_base = require "st.zigbee.cluster_base" +local zcl_clusters = require "st.zigbee.zcl.clusters" +local data_types = require "st.zigbee.data_types" + +local aqara = require "aqara_cluster" + +local OnOff = zcl_clusters.OnOff +local Level = zcl_clusters.Level +local ColorControl = zcl_clusters.ColorControl + +-- Aqara manufacturer-specific preference keys +local nightLightMode = "stse.nightLightMode" +local nightLightEndTime = "stse.nightLightEndTime" +local nightLightStartTime = "stse.nightLightStartTime" +local muteBeep = "stse.muteBeep" +local thermostatCtrl = "stse.thermostatCtrl" + +-- AC code field values (see send_ac_code for the bit layout) +local PWR = { OFF = 0x0, ON = 0x1 } +local MODE = { HEAT = 0x0, DRYAIR = 0x3, COOL = 0x4, FANONLY = 0x5, INVALID = 0xF } +local FAN_LOW = 0x0 +local FAN_MID = 0x1 +local FAN_HIGH = 0x2 +local FAN_INVALID = 0xF +local SWING_ON = 0x0 +local SWING_OFF = 0x1 + +-- SmartThings fanMode capability values +local SPEED = { + LOW = "low", + MEDIUM = "medium", + HIGH = "high", +} +local MODE_TO_FAN = { [SPEED.LOW] = FAN_LOW, [SPEED.MEDIUM] = FAN_MID, [SPEED.HIGH] = FAN_HIGH } +local FAN_TO_MODE = { [0] = SPEED.LOW, [1] = SPEED.MEDIUM, [2] = SPEED.HIGH } + +-- SmartThings fanOscillationMode capability values +local OSC = { + SWING = "swing", + FIXED = "fixed", +} +local ST_FAN_TO_SWING = { + [OSC.SWING] = SWING_ON, + [OSC.FIXED] = SWING_OFF, +} + +-- SmartThings thermostatMode capability values +local ST_MODE = { + OFF = "off", + HEAT = "heat", + DRYAIR = "dryair", + COOL = "cool", + FANONLY = "fanonly", +} + +-- SmartThings thermostatMode -> AC parameters +local ST_TO_AC = { + [ST_MODE.OFF] = { pwr = PWR.OFF, mode = MODE.INVALID, fan = FAN_INVALID }, + [ST_MODE.HEAT] = { pwr = PWR.ON, mode = MODE.HEAT, fan = FAN_MID }, + [ST_MODE.DRYAIR] = { pwr = PWR.ON, mode = MODE.DRYAIR, fan = FAN_MID }, + [ST_MODE.COOL] = { pwr = PWR.ON, mode = MODE.COOL, fan = FAN_MID }, + [ST_MODE.FANONLY] = { pwr = PWR.ON, mode = MODE.FANONLY, fan = FAN_MID }, +} + +-- AC mode bits -> SmartThings thermostatMode +local AC_MODE_TO_ST = { + [0x0] = ST_MODE.HEAT, + [0x3] = ST_MODE.DRYAIR, + [0x4] = ST_MODE.COOL, + [0x5] = ST_MODE.FANONLY, +} + +-- Color temperature range (mireds) for the LED light +local MIRED_MIN = 153 +local MIRED_MAX = 370 + +local function clamp(v, lo, hi) return math.max(lo, math.min(hi, v)) end +local function kelvin_to_mired(k) return math.floor(1000000 / k) end +local function mired_to_kelvin(m) return math.floor(1000000 / m) end + +-- Encode and send the 64-bit AC control code as an Aqara manufacturer attribute. +-- A nibble of 0xF means "no change"; the default 0xFFFFFFFFFFFFFFFF leaves +-- every field untouched so callers only need to set what they want to change. +-- pwr : bits31-28 0=off 1=on +-- mode : bits27-24 0=heat 3=dryair 4=cool 5=fanonly +-- fan : bits23-20 0=low 1=mid 2=high 3=auto +-- swing : bits17-16 0=swing 1=fixed +-- setpoint : bits63-48 Celsius x 100 +local function send_ac_code(device, params) + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + + if params.setpoint ~= nil then + local sp_raw = math.floor(clamp(params.setpoint, 16, 45) * 100) & 0xFFFF + hi32 = (sp_raw << 16) | (hi32 & 0xFFFF) + end + + if params.pwr ~= nil then + lo32 = (lo32 & 0x0FFFFFFF) | ((params.pwr & 0xF) << 28) + end + + if params.mode ~= nil then + lo32 = (lo32 & 0xF0FFFFFF) | ((params.mode & 0xF) << 24) + end + + if params.fan ~= nil then + lo32 = (lo32 & 0xFF0FFFFF) | ((params.fan & 0xF) << 20) + end + + if params.swing ~= nil then + lo32 = (lo32 & 0xFFFCFFFF) | ((params.swing & 0x3) << 16) + end + + local bytes = string.char( + (hi32 >> 24) & 0xFF, + (hi32 >> 16) & 0xFF, + (hi32 >> 8) & 0xFF, + hi32 & 0xFF, + (lo32 >> 24) & 0xFF, + (lo32 >> 16) & 0xFF, + (lo32 >> 8) & 0xFF, + lo32 & 0xFF + ) + + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_AC_CODE, aqara.MFG_CODE, + data_types.Uint64, bytes + )) +end + +-- Per-mode state persistence: remember the last swing/fan used in each +-- thermostat mode so they can be restored when the user returns to it. +-- Setpoint is shared across modes and read directly from the capability state. +local FIELD = { + SWING = "swing", + FAN_MODE = "fan_mode", +} + +-- Fields tracked per mode (modes not listed here have no per-mode state). +local MODE_FIELDS = { + [ST_MODE.HEAT] = { FIELD.SWING, FIELD.FAN_MODE }, + [ST_MODE.COOL] = { FIELD.SWING, FIELD.FAN_MODE }, + [ST_MODE.DRYAIR] = { FIELD.SWING, FIELD.FAN_MODE }, + [ST_MODE.FANONLY] = { FIELD.FAN_MODE }, +} + +-- Initial values when no saved state exists yet for a mode. +local MODE_DEFAULTS = { + [ST_MODE.HEAT] = { swing = OSC.SWING, fan_mode = SPEED.MEDIUM }, + [ST_MODE.COOL] = { swing = OSC.SWING, fan_mode = SPEED.MEDIUM }, + [ST_MODE.DRYAIR] = { swing = OSC.SWING, fan_mode = SPEED.MEDIUM }, + [ST_MODE.FANONLY] = { fan_mode = SPEED.MEDIUM }, +} + +local function save_mode_state(device, mode, field, value) + device:set_field("mode_state." .. mode .. "." .. field, value, { persist = true }) +end + +local function load_mode_state(device, mode, field) + return device:get_field("mode_state." .. mode .. "." .. field) +end + +local function save_current_mode_field(device, field, value) + local mode = device:get_field("thermostat_mode") or ST_MODE.OFF + local fields = MODE_FIELDS[mode] + if fields then + for _, f in ipairs(fields) do + if f == field then + save_mode_state(device, mode, field, value) + return + end + end + end +end + +-- Capture the current capability state for each field tracked by the given +-- mode. Used right before switching modes so any in-flight changes (e.g., a +-- setpoint set via the UI but not yet confirmed by the device) are preserved. +local function snapshot_mode_state(device, mode) + local fields = MODE_FIELDS[mode] + if not fields then return end + + for _, field in ipairs(fields) do + local v + if field == FIELD.SWING then + v = device:get_latest_state("main", + capabilities.fanOscillationMode.ID, + capabilities.fanOscillationMode.fanOscillationMode.NAME) + elseif field == FIELD.FAN_MODE then + v = device:get_latest_state("main", + capabilities.fanMode.ID, + capabilities.fanMode.fanMode.NAME) + end + if v ~= nil then + save_mode_state(device, mode, field, v) + end + end +end + +-- Re-emit saved values for the entered mode and push them to the AC in a +-- single batched code, falling back to MODE_DEFAULTS on first use. +local function restore_mode_state(device, st_mode) + local fields = MODE_FIELDS[st_mode] + if not fields then return end + + local defaults = MODE_DEFAULTS[st_mode] or {} + local swing, fan = nil, nil + + for _, field in ipairs(fields) do + if field == FIELD.SWING then + local v = load_mode_state(device, st_mode, FIELD.SWING) or defaults.swing + if v ~= nil then + swing = ST_FAN_TO_SWING[v] + device:set_field("fan_mode", v) + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(v)) + end + elseif field == FIELD.FAN_MODE then + local v = load_mode_state(device, st_mode, FIELD.FAN_MODE) or defaults.fan_mode + if v ~= nil then + fan = MODE_TO_FAN[v] + device:set_field("fan_mode_ac", fan) + device:emit_event(capabilities.fanMode.fanMode(v)) + end + end + end + + if swing ~= nil or fan ~= nil then + send_ac_code(device, { swing = swing, fan = fan }) + end +end + + +-- Capability handlers +local function handle_switch_on(driver, device, cmd) + device:send(OnOff.server.commands.On(device)) +end + +local function handle_switch_off(driver, device, cmd) + device:send(OnOff.server.commands.Off(device)) +end + +local function handle_switch_level(driver, device, cmd) + local level = cmd.args.level + local zb = math.floor(clamp(level, 1, 100) / 100 * 0xFE) + device:send(Level.server.commands.MoveToLevelWithOnOff( + device, data_types.Uint8(zb), data_types.Uint16(0x0000) + )) +end + +local function handle_color_temperature(driver, device, cmd) + local kelvin = clamp(cmd.args.temperature, 2700, 6500) + local mired = clamp(kelvin_to_mired(kelvin), MIRED_MIN, MIRED_MAX) + device:send(ColorControl.server.commands.MoveToColorTemperature( + device, data_types.Uint16(mired), data_types.Uint16(0x0000) + )) +end + +local function handle_thermostat_mode(driver, device, cmd) + local st_mode = cmd.args.mode + local ac = ST_TO_AC[st_mode] + if not ac then return end + + local prev_mode = device:get_field("thermostat_mode") + if prev_mode and prev_mode ~= st_mode then + snapshot_mode_state(device, prev_mode) + end + + local pwr = (st_mode == ST_MODE.OFF) and PWR.OFF or PWR.ON + -- Setpoint is shared across modes; on entry to HEAT, push the last value + -- the user set so the device matches what the UI is currently showing. + local setpoint = nil + if st_mode == ST_MODE.HEAT then + local state = device:get_latest_state("main", + capabilities.thermostatHeatingSetpoint.ID, + capabilities.thermostatHeatingSetpoint.heatingSetpoint.NAME) + if state ~= nil then + setpoint = clamp(state, 16, 45) + end + end + + send_ac_code(device, { pwr = pwr, mode = ac.mode, setpoint = setpoint }) + device:set_field("thermostat_mode", st_mode) + device:emit_event(capabilities.thermostatMode.thermostatMode(st_mode)) + restore_mode_state(device, st_mode) + + if st_mode ~= ST_MODE.OFF then + device:set_field("pending_on_mode", st_mode) + else + device:set_field("pending_on_mode", nil) + end +end + +local function handle_heating_setpoint(driver, device, cmd) + local temp_c = clamp(cmd.args.setpoint, 16, 45) + device:set_field("heating_setpoint", temp_c) + + local cur = device:get_field("thermostat_mode") or ST_MODE.OFF + if cur == ST_MODE.HEAT then + send_ac_code(device, { setpoint = temp_c }) + end + + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint( + { value = temp_c, unit = "C" } + )) +end + +local function handle_fan_oscillation_mode(driver, device, cmd) + local st_fan = cmd.args.fanOscillationMode + local swing = ST_FAN_TO_SWING[st_fan] or SWING_ON + + device:set_field("fan_mode", st_fan) + send_ac_code(device, { swing = swing }) + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(st_fan)) +end + +local function handle_fan_mode(driver, device, cmd) + local fan_mode = cmd.args.fanMode + local fan = MODE_TO_FAN[fan_mode] or FAN_MID + device:set_field("fan_mode_ac", fan) + send_ac_code(device, { fan = fan }) +end + +-- Zigbee attribute handlers +local function on_off_attr_handler(driver, device, value, zb_rx) + device:emit_event(capabilities.switch.switch(value.value and "on" or "off")) +end + +local function current_level_handler(driver, device, value, zb_rx) + local pct = clamp(math.floor(value.value / 0xFE * 100), 1, 100) + device:emit_event(capabilities.switchLevel.level(pct)) +end + +local function color_temp_handler(driver, device, value, zb_rx) + local mired = value.value + if mired == 0 then return end + local kelvin = clamp(mired_to_kelvin(mired), 2700, 6500) + device:emit_event(capabilities.colorTemperature.colorTemperature(kelvin)) +end + +-- Decode the AC code reported by the device, emit matching capability events, +-- and persist the per-mode state so values are restored when the user returns +-- to that mode. +local function ac_code_attr_handler(driver, device, value, zb_rx) + local raw = value.value + local hi32, lo32 + + -- The attribute is a Uint64 but may arrive either as a raw integer or as + -- the 8-byte big-endian payload depending on the runtime path. + if type(raw) == "string" then + local b = { string.byte(raw, 1, 8) } + hi32 = ((b[1] or 0) << 24) | ((b[2] or 0) << 16) | ((b[3] or 0) << 8) | (b[4] or 0) + lo32 = ((b[5] or 0) << 24) | ((b[6] or 0) << 16) | ((b[7] or 0) << 8) | (b[8] or 0) + else + hi32 = (raw >> 32) & 0xFFFFFFFF + lo32 = raw & 0xFFFFFFFF + end + + local pwr = (lo32 >> 28) & 0xF + local mode = (lo32 >> 24) & 0xF + local fan_set = (lo32 >> 20) & 0xF + local b15_8 = (lo32 >> 8) & 0xFF + local b7_0 = lo32 & 0xFF + local bits7_2 = (b7_0 >> 2) & 0x3F + + -- The setpoint nibbles are only trustworthy when the surrounding sentinel + -- bytes match this pattern; otherwise the device is reporting "no change". + local hi_valid = (b15_8 >= 0xFE) and (bits7_2 == 63) + local setpoint_raw = (hi32 >> 16) & 0xFFFF + + if hi_valid and setpoint_raw ~= 0xFFFF then + local sp = setpoint_raw / 100.0 + device:set_field("heating_setpoint", sp) + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint( + { value = sp, unit = "C" } + )) + end + + -- fan speed (bits23-20): 0=low, 1=mid, 2=high; 3=auto and 0xF are ignored. + if fan_set <= 2 then + local fan_mode = FAN_TO_MODE[fan_set] or SPEED.MEDIUM + device:set_field("fan_mode_ac", fan_set) + save_current_mode_field(device, FIELD.FAN_MODE, fan_mode) + device:emit_event(capabilities.fanMode.fanMode(fan_mode)) + end + + -- swing mode (bits17-16): 0=swing, 1=fixed; other values are ignored. + local swing_bit = (lo32 >> 16) & 0x3 + if swing_bit == 0 then + device:set_field("fan_mode", OSC.SWING) + save_current_mode_field(device, FIELD.SWING, OSC.SWING) + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(OSC.SWING)) + elseif swing_bit == 1 then + device:set_field("fan_mode", OSC.FIXED) + save_current_mode_field(device, FIELD.SWING, OSC.FIXED) + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(OSC.FIXED)) + end + + -- 0xF in the pwr nibble is the "no change" sentinel; mode bits are unreliable. + if pwr == 0xF then return end + + local st_mode + if pwr == 0x0 then + st_mode = ST_MODE.OFF + else + st_mode = AC_MODE_TO_ST[mode] or ST_MODE.HEAT + end + + -- Suppress a transient "off" report that arrives between a mode change + -- request and the device confirming the new mode. + local pending = device:get_field("pending_on_mode") + if st_mode ~= ST_MODE.OFF then + device:set_field("pending_on_mode", nil) + else + if pending ~= nil then return end + end + + local current = device:get_field("thermostat_mode") + if current ~= st_mode then + device:set_field("thermostat_mode", st_mode) + device:emit_event(capabilities.thermostatMode.thermostatMode(st_mode)) + end +end + +local SUPPORTED_THERMOSTAT_MODES = { + capabilities.thermostatMode.thermostatMode.off.NAME, + capabilities.thermostatMode.thermostatMode.heat.NAME, + capabilities.thermostatMode.thermostatMode.dryair.NAME, + capabilities.thermostatMode.thermostatMode.cool.NAME, + capabilities.thermostatMode.thermostatMode.fanonly.NAME +} + +local SUPPORTED_FAN_MODES = { + capabilities.fanOscillationMode.fanOscillationMode.swing.NAME, + capabilities.fanOscillationMode.fanOscillationMode.fixed.NAME +} + +local SUPPORTED_SPEED_MODES = { SPEED.LOW, SPEED.MEDIUM, SPEED.HIGH } + +-- Lifecycle handlers +local function device_init(driver, device) + device:emit_event(capabilities.thermostatMode.supportedThermostatModes( + SUPPORTED_THERMOSTAT_MODES, { visibility = { displayed = false } } + )) + device:emit_event(capabilities.fanOscillationMode.supportedFanOscillationModes( + SUPPORTED_FAN_MODES, { visibility = { displayed = false } } + )) + device:emit_event(capabilities.fanMode.supportedFanModes( + SUPPORTED_SPEED_MODES, { visibility = { displayed = false } } + )) + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpointRange( + { value = { minimum = 16, maximum = 45, step = 1 }, unit = "C" } + )) + device:send(OnOff.attributes.OnOff:read(device)) + device:send(Level.attributes.CurrentLevel:read(device)) + device:send(ColorControl.attributes.ColorTemperatureMireds:read(device)) +end + +local function device_added(driver, device) + if device:get_latest_state("main", capabilities.thermostatHeatingSetpoint.ID, + capabilities.thermostatHeatingSetpoint.heatingSetpoint.NAME) == nil then + device:emit_event(capabilities.thermostatHeatingSetpoint.heatingSetpoint( + { value = 25, unit = "C" } + )) + send_ac_code(device, { setpoint = 25 }) + end + if device:get_latest_state("main", capabilities.fanMode.ID, + capabilities.fanMode.fanMode.NAME) == nil then + device:emit_event(capabilities.fanMode.fanMode(SPEED.MEDIUM)) + end + if device:get_latest_state("main", capabilities.fanOscillationMode.ID, + capabilities.fanOscillationMode.fanOscillationMode.NAME) == nil then + device:emit_event(capabilities.fanOscillationMode.fanOscillationMode(OSC.SWING)) + end +end + +local function send_night_light(device, new) + local start_time = (tonumber(new[nightLightStartTime]) * 60) & 0xFFF + local end_time = (tonumber(new[nightLightEndTime]) * 60) & 0xFFF + local on_val = (end_time << 12) | start_time + local val = new[nightLightMode] and on_val or (on_val + 1) + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_NIGHT_LIGHT, + aqara.MFG_CODE, data_types.Uint32, val)) +end + +local function info_changed(driver, device, event, args) + if args.old_st_store.preferences == nil then return end + + local old = args.old_st_store.preferences + local new = device.preferences + + -- Night-light: re-send when the on/off toggle flips, or when the schedule + -- changes while the feature is enabled. + local mode_changed = old[nightLightMode] ~= new[nightLightMode] + local time_changed = + old[nightLightEndTime] ~= new[nightLightEndTime] or + old[nightLightStartTime] ~= new[nightLightStartTime] + if mode_changed then + send_night_light(device, new) + elseif time_changed and new[nightLightMode] == true then + send_night_light(device, new) + end + + -- Mute beep ("do not disturb"). On first init we always push the value so + -- the device matches the preference even if it was changed before pairing. + if old[muteBeep] ~= new[muteBeep] or device:get_field("inited") == nil then + local val = new[muteBeep] and 1 or 0 + device:set_field("inited", true) + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_DND_BEEP, + aqara.MFG_CODE, data_types.Uint8, val)) + -- When un-muted, configure the DND window to span 24h (00:18 - 00:18). + if val == 0 then + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_DND_TIME, + aqara.MFG_CODE, data_types.Uint32, 0x00120012)) + end + end + + -- Constant-temperature thermostat control switch. + if old[thermostatCtrl] ~= new[thermostatCtrl] then + device:send(cluster_base.write_manufacturer_specific_attribute( + device, aqara.CLUSTER_ID, aqara.ATTR_THERMOSTAT_CTRL_SW, + aqara.MFG_CODE, data_types.Uint8, new[thermostatCtrl] and 1 or 0)) + end +end + +local aqara_bathroom_heater_driver = ZigbeeDriver("aqara-bathroom-heater-t1", { + supported_capabilities = { + capabilities.switch, + capabilities.switchLevel, + capabilities.colorTemperature, + capabilities.thermostatMode, + capabilities.thermostatHeatingSetpoint, + capabilities.fanOscillationMode, + capabilities.fanMode, + }, + + capability_handlers = { + [capabilities.switch.ID] = { + [capabilities.switch.commands.on.NAME] = handle_switch_on, + [capabilities.switch.commands.off.NAME] = handle_switch_off, + }, + [capabilities.switchLevel.ID] = { + [capabilities.switchLevel.commands.setLevel.NAME] = handle_switch_level, + }, + [capabilities.colorTemperature.ID] = { + [capabilities.colorTemperature.commands.setColorTemperature.NAME] = handle_color_temperature, + }, + [capabilities.thermostatMode.ID] = { + [capabilities.thermostatMode.commands.setThermostatMode.NAME] = handle_thermostat_mode, + }, + [capabilities.thermostatHeatingSetpoint.ID] = { + [capabilities.thermostatHeatingSetpoint.commands.setHeatingSetpoint.NAME] = handle_heating_setpoint, + }, + [capabilities.fanOscillationMode.ID] = { + [capabilities.fanOscillationMode.commands.setFanOscillationMode.NAME] = handle_fan_oscillation_mode, + }, + [capabilities.fanMode.ID] = { + [capabilities.fanMode.commands.setFanMode.NAME] = handle_fan_mode, + }, + }, + + zigbee_handlers = { + attr = { + [OnOff.ID] = { + [OnOff.attributes.OnOff.ID] = on_off_attr_handler, + }, + [Level.ID] = { + [Level.attributes.CurrentLevel.ID] = current_level_handler, + }, + [ColorControl.ID] = { + [ColorControl.attributes.ColorTemperatureMireds.ID] = color_temp_handler, + }, + [aqara.CLUSTER_ID] = { + [aqara.ATTR_AC_CODE] = ac_code_attr_handler, + }, + }, + }, + health_check = false, + lifecycle_handlers = { + init = device_init, + added = device_added, + infoChanged = info_changed, + }, +}) + +aqara_bathroom_heater_driver:run() diff --git a/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua b/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua new file mode 100644 index 0000000000..66a97da8e3 --- /dev/null +++ b/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua @@ -0,0 +1,1112 @@ +-- Copyright 2026 SmartThings, Inc. +-- Licensed under the Apache License, Version 2.0 +-- +-- Consolidated test cases for the Aqara Bath Heater T1 SmartThings Edge driver. +-- +-- IMPORTANT: The test framework fires an "init" lifecycle event before every +-- test (the driver must complete its startup sequence before the test body +-- can run). Because `device_init` emits multiple capability events +-- (supported*, range) and issues three Zigbee attribute reads, `test_init` +-- pre-registers those expectations BEFORE calling `add_test_device(...)`, so +-- each individual test body can ignore the init emissions and focus only on +-- its own test-specific expectations. + +local test = require "integration_test" +local t_utils = require "integration_test.utils" +local capabilities = require "st.capabilities" +local zigbee_test_utils = require "integration_test.zigbee_test_utils" +local cluster_base = require "st.zigbee.cluster_base" +local data_types = require "st.zigbee.data_types" +local clusters = require "st.zigbee.zcl.clusters" + +local OnOff = clusters.OnOff +local Level = clusters.Level +local ColorControl = clusters.ColorControl + +local AQARA_CLUSTER_ID = 0xFCC0 +local AQARA_MFG_CODE = 0x115F +local ATTR_AC_CODE = 0x024F +local ATTR_THERMOSTAT_CTRL_SW = 0x02BE +local ATTR_DND_BEEP = 0x0256 +local ATTR_DND_TIME = 0x0257 +local ATTR_NIGHT_LIGHT = 0x0518 + +local mock_device = test.mock_device.build_test_zigbee_device({ + profile = t_utils.get_profile_definition("aqara-bath-heater.yml"), + fingerprinted_endpoint_id = 0x01, + zigbee_endpoints = { + [1] = { + id = 1, + manufacturer = "Aqara", + model = "lumi.bhf_light.acn001", + server_clusters = { 0x0006, 0x0008, 0x0300, 0xFCC0 } + } + } +}) + +zigbee_test_utils.prepare_zigbee_env_info() + +local function test_init() + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.supportedThermostatModes( + { "off", "heat", "dryair", "cool", "fanonly" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.supportedFanOscillationModes( + { "swing", "fixed" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.supportedFanModes( + { "low", "medium", "high" }, + { visibility = { displayed = false } }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpointRange( + { value = { minimum = 16, maximum = 45, step = 1 }, unit = "C" }))) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + test.mock_device.add_test_device(mock_device) +end +test.set_test_init_function(test_init) + +-- ---------------------------------------------------------------------------- +-- Helpers +-- ---------------------------------------------------------------------------- + +-- Build the 8-byte big-endian raw payload for the Aqara AC-code Uint64 attr. +local function ac_code_bytes(hi32, lo32) + return string.char( + (hi32 >> 24) & 0xFF, + (hi32 >> 16) & 0xFF, + (hi32 >> 8) & 0xFF, + hi32 & 0xFF, + (lo32 >> 24) & 0xFF, + (lo32 >> 16) & 0xFF, + (lo32 >> 8) & 0xFF, + lo32 & 0xFF + ) +end + +local function expect_ac_code_send(hi32, lo32) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_AC_CODE, AQARA_MFG_CODE, + data_types.Uint64, ac_code_bytes(hi32, lo32)) }) +end + +local function build_ac_code_report(hi32, lo32) + return zigbee_test_utils.build_attribute_report(mock_device, AQARA_CLUSTER_ID, + { { ATTR_AC_CODE, data_types.Uint64.ID, ac_code_bytes(hi32, lo32) } }) +end + +-- ============================================================================ +-- 1. CAPABILITY COMMAND HANDLERS +-- ============================================================================ + +-- switch.on / switch.off ------------------------------------------------------ + +test.register_coroutine_test( + "Capability switch.on should send OnOff.On zigbee command", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "on", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.On(mock_device) }) + end +) + +test.register_coroutine_test( + "Capability switch.off should send OnOff.Off zigbee command", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "switch", component = "main", command = "off", args = {} } }) + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.server.commands.Off(mock_device) }) + end +) + +-- switchLevel.setLevel -------------------------------------------------------- + +test.register_coroutine_test( + "Capability switchLevel 50 should scale to zigbee level 0x7F", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "switchLevel", + component = "main", + command = "setLevel", + args = { 50 } + } }) + test.socket.zigbee:__expect_send({ mock_device.id, + Level.server.commands.MoveToLevelWithOnOff(mock_device, + data_types.Uint8(math.floor(50 / 100 * 0xFE)), + data_types.Uint16(0x0000)) }) + end +) + +test.register_coroutine_test( + "Capability switchLevel 100 should scale to zigbee level 0xFE", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "switchLevel", + component = "main", + command = "setLevel", + args = { 100 } + } }) + test.socket.zigbee:__expect_send({ mock_device.id, + Level.server.commands.MoveToLevelWithOnOff(mock_device, + data_types.Uint8(0xFE), data_types.Uint16(0x0000)) }) + end +) + +test.register_coroutine_test( + "Capability switchLevel 0 should be clamped to 1", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "switchLevel", + component = "main", + command = "setLevel", + args = { 0 } + } }) + test.socket.zigbee:__expect_send({ mock_device.id, + Level.server.commands.MoveToLevelWithOnOff(mock_device, + data_types.Uint8(math.floor(1 / 100 * 0xFE)), + data_types.Uint16(0x0000)) }) + end +) + +-- NOTE: setLevel(150) would exercise the driver's clamp(..., 1, 100) but the +-- capability framework validates `level` against its 0..100 schema BEFORE the +-- handler runs, so the clamp is unreachable via the capability command path. + +-- colorTemperature.setColorTemperature ---------------------------------------- + +test.register_coroutine_test( + "Capability colorTemperature 4000K should send mired=250", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "colorTemperature", + component = "main", + command = "setColorTemperature", + args = { 4000 } + } }) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.server.commands.MoveToColorTemperature(mock_device, + data_types.Uint16(250), data_types.Uint16(0x0000)) }) + end +) + +test.register_coroutine_test( + "Capability colorTemperature 2700K should send mired=370 (boundary)", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "colorTemperature", + component = "main", + command = "setColorTemperature", + args = { 2700 } + } }) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.server.commands.MoveToColorTemperature(mock_device, + data_types.Uint16(370), data_types.Uint16(0x0000)) }) + end +) + +test.register_coroutine_test( + "Capability colorTemperature 6500K should send mired=153 (boundary)", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "colorTemperature", + component = "main", + command = "setColorTemperature", + args = { 6500 } + } }) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.server.commands.MoveToColorTemperature(mock_device, + data_types.Uint16(153), data_types.Uint16(0x0000)) }) + end +) + +-- NOTE: setColorTemperature(2000) and (8000) would test the clamp, but the +-- profile restricts the colorTemperature range to [2700, 6500], so those +-- values are rejected by framework validation. The incoming Zigbee clamp path +-- (color_temp_handler with mired that yields kelvin > 6500 or < 2700) IS +-- exercised by "ColorTemperatureMireds 100 should clamp kelvin to 6500" below. + +-- thermostatMode.setThermostatMode -------------------------------------------- + +test.register_coroutine_test( + "Capability thermostatMode 'off' should send AC off code and emit event", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "off" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x0 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0xF << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("off"))) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'heat' should send AC code and restore defaults", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "heat" } + } }) + + -- No prior heatingSetpoint latest state, so the first AC code carries + -- only pwr=1 and mode=0 (no setpoint nibble set). + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x1 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'cool' should send AC code (mode=4)", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "cool" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x4 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("cool"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x1 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'dryair' should send AC code (mode=3)", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "dryair" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x3 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("dryair"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x1 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +test.register_coroutine_test( + "Capability thermostatMode 'fanonly' should send AC code (mode=5) and restore only fan", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "fanonly" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x5 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("fanonly"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = (0xFFFFFFFF & 0xFF0FFFFF) | (0x1 << 20) + expect_ac_code_send(r_hi, r_lo) + end +) + +-- NOTE: setThermostatMode("unsupported_mode") would hit the driver's +-- `if not ac then return end` guard, but the capability framework validates +-- `mode` against its enum and rejects unknown values before the handler runs. + +-- thermostatHeatingSetpoint.setHeatingSetpoint -------------------------------- + +test.register_coroutine_test( + "Capability heatingSetpoint 28 in non-heat mode emits only event (no AC code)", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = { 28 } + } }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 28, unit = "C" }))) + end +) + +-- NOTE: setHeatingSetpoint(10) / (60) would exercise the clamp(..., 16, 45), +-- but the profile constrains the setpoint to [16, 45] so framework validation +-- rejects those values. The same clamp is exercised via restore_mode_state +-- ("setThermostatMode heat should restore saved setpoint/swing/fan from +-- mode_state") below. + +test.register_coroutine_test( + "Capability heatingSetpoint in heat mode should also send AC code with setpoint", + function() + mock_device:set_field("thermostat_mode", "heat") + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = { 30 } + } }) + + local hi32 = ((3000 & 0xFFFF) << 16) | (0xFFFFFFFF & 0xFFFF) + local lo32 = 0xFFFFFFFF + expect_ac_code_send(hi32, lo32) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 30, unit = "C" }))) + end +) + +-- fanOscillationMode.setFanOscillationMode ------------------------------------ + +test.register_coroutine_test( + "Capability fanOscillationMode 'swing' sends AC code with swing bits=0", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanOscillationMode", + component = "main", + command = "setFanOscillationMode", + args = { "swing" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(hi32, lo32) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + end +) + +test.register_coroutine_test( + "Capability fanOscillationMode 'fixed' sends AC code with swing bits=1", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanOscillationMode", + component = "main", + command = "setFanOscillationMode", + args = { "fixed" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0xFFFCFFFF) | (0x1 << 16) + expect_ac_code_send(hi32, lo32) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("fixed"))) + end +) + +-- fanMode.setFanMode ---------------------------------------------------------- + +test.register_coroutine_test( + "Capability fanMode 'low' sends AC code with fan bits=0", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanMode", + component = "main", + command = "setFanMode", + args = { "low" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFF0FFFFF) | (0x0 << 20)) + end +) + +test.register_coroutine_test( + "Capability fanMode 'medium' sends AC code with fan bits=1", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanMode", + component = "main", + command = "setFanMode", + args = { "medium" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFF0FFFFF) | (0x1 << 20)) + end +) + +test.register_coroutine_test( + "Capability fanMode 'high' sends AC code with fan bits=2", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanMode", + component = "main", + command = "setFanMode", + args = { "high" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFF0FFFFF) | (0x2 << 20)) + end +) + +-- NOTE: setFanMode("auto") would exercise the `MODE_TO_FAN[fan_mode] or +-- FAN_MID` fallback, but the profile's enabledValues restricts fanMode to +-- {"low","medium","high"}, so "auto" is rejected by framework validation. + +-- ============================================================================ +-- 2. ZIGBEE ATTRIBUTE HANDLERS +-- ============================================================================ + +-- OnOff ----------------------------------------------------------------------- + +test.register_coroutine_test( + "OnOff attribute true should emit switch.on", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, true) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.switch.switch.on())) + end +) + +test.register_coroutine_test( + "OnOff attribute false should emit switch.off", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, + OnOff.attributes.OnOff:build_test_attr_report(mock_device, false) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.switch.switch.off())) + end +) + +-- Level.CurrentLevel ---------------------------------------------------------- + +test.register_coroutine_test( + "CurrentLevel 0xFE should emit level 100", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(mock_device, 0xFE) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.switchLevel.level(100))) + end +) + +test.register_coroutine_test( + "CurrentLevel 0x7F should emit level 50", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(mock_device, 0x7F) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.switchLevel.level(math.floor(0x7F / 0xFE * 100)))) + end +) + +test.register_coroutine_test( + "CurrentLevel 0x00 should emit level 1 (clamped)", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, + Level.attributes.CurrentLevel:build_test_attr_report(mock_device, 0x00) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.switchLevel.level(1))) + end +) + +-- ColorControl.ColorTemperatureMireds ----------------------------------------- + +test.register_coroutine_test( + "ColorTemperatureMireds 250 should emit colorTemperature 4000K", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:build_test_attr_report(mock_device, 250) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.colorTemperature.colorTemperature(math.floor(1000000 / 250)))) + end +) + +test.register_coroutine_test( + "ColorTemperatureMireds 370 should emit colorTemperature ~2702K", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:build_test_attr_report(mock_device, 370) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.colorTemperature.colorTemperature(math.floor(1000000 / 370)))) + end +) + +test.register_coroutine_test( + "ColorTemperatureMireds 100 should clamp kelvin to 6500", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:build_test_attr_report(mock_device, 100) }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.colorTemperature.colorTemperature(6500))) + end +) + +test.register_coroutine_test( + "ColorTemperatureMireds 0 should be ignored (no event)", + function() + test.socket.zigbee:__queue_receive({ mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:build_test_attr_report(mock_device, 0) }) + end +) + +-- Aqara AC code (0xFCC0 / 0x024F) --------------------------------------------- + +test.register_coroutine_test( + "AC code report: heat + medium fan + swing + setpoint 25.00 should emit all events", + function() + local hi32 = (2500 << 16) | 0xFEFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + lo32 = (lo32 & 0xFFFCFFFF) | (0x0 << 16) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25.0, unit = "C" }))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + end +) + +test.register_coroutine_test( + "AC code report: pwr=0 (off) should emit thermostatMode 'off'", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x0 << 28) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + lo32 = (lo32 & 0xFFFCFFFF) | (0x1 << 16) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("fixed"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("off"))) + end +) + +test.register_coroutine_test( + "AC code report: pwr=0xF (invalid) should skip thermostatMode update", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0xFF0FFFFF) | (0x0 << 20) + lo32 = (lo32 & 0xFFFCFFFF) | (0x0 << 16) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("low"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + end +) + +test.register_coroutine_test( + "AC code report: fan=2 (high) and mode=4 (cool)", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x4 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x2 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("high"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("cool"))) + end +) + +test.register_coroutine_test( + "AC code report: unknown mode bits should fall back to 'heat'", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x7 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + end +) + +test.register_coroutine_test( + "AC code report: fanonly mode (5)", + function() + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x5 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("fanonly"))) + end +) + +test.register_coroutine_test( + "AC code report: pending_on_mode + incoming off should NOT overwrite mode", + function() + mock_device:set_field("pending_on_mode", "heat") + + local hi32 = 0xFFFFFFFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x0 << 28) -- pwr=0 (off) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) -- fan=1 (medium) + lo32 = (lo32 & 0xFFFCFFFF) | (0x0 << 16) -- swing bits=00 (swing) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + -- thermostatMode NOT emitted: pwr=0 → st_mode="off", but pending_on_mode="heat" + -- causes early return from the handler. + end +) + +test.register_coroutine_test( + "AC code report: setpoint 0xFFFF (invalid marker) should skip setpoint emit", + function() + local hi32 = (0xFFFF << 16) | 0xFEFF + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + end +) + +test.register_coroutine_test( + "AC code report: invalid frame (b15_8 < 0xFE) should skip setpoint emit", + function() + -- hi32 carries a valid-looking setpoint raw (2800 = 28.00°C); the "invalid" + -- frame marker lives in lo32 bits 15-8 (b15_8). Clearing them to 0x00 + -- makes hi_valid = false, so the setpoint emit MUST be suppressed even + -- though setpoint_raw != 0xFFFF. + local hi32 = (2800 << 16) | 0x0000 + local lo32 = 0xFFFFFFFF + lo32 = (lo32 & 0x0FFFFFFF) | (0x1 << 28) -- pwr=1 (on) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) -- mode=0 (heat) + lo32 = (lo32 & 0xFF0FFFFF) | (0x1 << 20) -- fan=1 (medium) + lo32 = lo32 & 0xFFFF00FF -- b15_8 = 0x00 → frame invalid + + test.socket.zigbee:__queue_receive({ mock_device.id, build_ac_code_report(hi32, lo32) }) + + -- Setpoint NOT emitted because hi_valid=false. + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + end +) + +-- ============================================================================ +-- 3. LIFECYCLE HANDLERS +-- ============================================================================ + +test.register_coroutine_test( + "Lifecycle init should emit supported* / range events and read attributes", + function() + -- This test only verifies the emissions fired by the automatic init; + -- those expectations are already registered in test_init(). + end +) + +test.register_coroutine_test( + "Lifecycle added should emit defaults (setpoint=25, fan=medium, swing=swing) + AC code", + function() + test.socket.device_lifecycle:__queue_receive({ mock_device.id, "added" }) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 25, unit = "C" }))) + + local hi32 = ((2500 & 0xFFFF) << 16) | (0xFFFFFFFF & 0xFFFF) + expect_ac_code_send(hi32, 0xFFFFFFFF) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + end +) + +-- info_changed helpers -------------------------------------------------------- + +local function expected_night_light_value(start_h, end_h, enabled) + local start_time = (start_h * 60) & 0xFFF + local end_time = (end_h * 60) & 0xFFF + local on_val = (end_time << 12) | start_time + return enabled and on_val or (on_val + 1) +end + +test.register_coroutine_test( + "infoChanged nightLightMode on (first init) should send night-light + DND-beep + DND-time", + function() + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9, + ["stse.muteBeep"] = false + } + })) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, true)) }) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_BEEP, AQARA_MFG_CODE, + data_types.Uint8, 0) }) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_TIME, AQARA_MFG_CODE, + data_types.Uint32, 0x00120012) }) + end +) + +test.register_coroutine_test( + "infoChanged nightLightMode off should send night-light disabled value", + function() + mock_device:set_field("inited", true) + + -- The profile default is nightLightMode=false, so passing `false` again + -- would produce old==new and the handler's `mode_changed` branch would + -- not fire. First flip it ON (consuming the resulting "enabled" write), + -- then flip it OFF — which is the transition this test actually covers. + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9 + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, true)) }) + + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = false, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9 + } + })) + + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, false)) }) + end +) + +test.register_coroutine_test( + "infoChanged night-light time changed while enabled should resend night-light", + function() + mock_device:set_field("inited", true) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 22, + ["stse.nightLightEndTime"] = 8 + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(22, 8, true)) }) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.nightLightMode"] = true, + ["stse.nightLightStartTime"] = 21, + ["stse.nightLightEndTime"] = 9 + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_NIGHT_LIGHT, AQARA_MFG_CODE, + data_types.Uint32, expected_night_light_value(21, 9, true)) }) + end +) + +test.register_coroutine_test( + "infoChanged muteBeep toggled on should write DND-beep=1", + function() + mock_device:set_field("inited", true) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.muteBeep"] = true + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_BEEP, AQARA_MFG_CODE, + data_types.Uint8, 1) }) + end +) + +test.register_coroutine_test( + "infoChanged thermostatCtrl toggled off should write 0", + function() + mock_device:set_field("inited", true) + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.thermostatCtrl"] = false, + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_THERMOSTAT_CTRL_SW, AQARA_MFG_CODE, + data_types.Uint8, 0) }) + end +) + +test.register_coroutine_test( + "infoChanged when not yet initialized should trigger initialization", + function() + -- mock_device:set_field("inited", "") + test.socket.device_lifecycle:__queue_receive(mock_device:generate_info_changed({ + preferences = { + ["stse.muteBeep"] = true + } + })) + test.socket.zigbee:__expect_send({ mock_device.id, + cluster_base.write_manufacturer_specific_attribute(mock_device, + AQARA_CLUSTER_ID, ATTR_DND_BEEP, AQARA_MFG_CODE, + data_types.Uint8, 1) }) + end +) + +-- ============================================================================ +-- 4. EDGE CASES / BRANCH COVERAGE +-- ============================================================================ + +test.register_coroutine_test( + "thermostatMode heat with existing heatingSetpoint 30 latest state uses 30", + function() + -- Seed the latest state via the real capability path: setHeatingSetpoint + -- emits the event, which updates the attribute's latest state. The current + -- thermostat_mode is still "off" (default), so no AC-code write happens here. + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = { 30 } + } }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 30, unit = "C" }))) + + -- Now switch to heat mode. get_latest_state() should return 30 and the + -- first AC code must carry setpoint=30. + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "heat" } + } }) + + local hi32 = ((3000 & 0xFFFF) << 16) | (0xFFFFFFFF & 0xFFFF) + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + + -- restore_mode_state restores swing/fan defaults; setpoint is shared + -- across modes and not part of mode_state. + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("swing"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("medium"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x1 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x0 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +test.register_coroutine_test( + "setHeatingSetpoint while mode is 'off' hits save_current_mode_field no-op", + function() + mock_device:set_field("thermostat_mode", "off") + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatHeatingSetpoint", + component = "main", + command = "setHeatingSetpoint", + args = { 22 } + } }) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatHeatingSetpoint.heatingSetpoint({ value = 22, unit = "C" }))) + end +) + +test.register_coroutine_test( + "setFanOscillationMode while mode is 'fanonly' hits MODE_FIELDS no-match path", + function() + mock_device:set_field("thermostat_mode", "fanonly") + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "fanOscillationMode", + component = "main", + command = "setFanOscillationMode", + args = { "fixed" } + } }) + expect_ac_code_send(0xFFFFFFFF, (0xFFFFFFFF & 0xFFFCFFFF) | (0x1 << 16)) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("fixed"))) + end +) + +test.register_coroutine_test( + "setThermostatMode heat should restore saved swing/fan from mode_state", + function() + mock_device:set_field("mode_state.heat.swing", "fixed") + mock_device:set_field("mode_state.heat.fan_mode", "high") + + test.socket.capability:__queue_receive({ mock_device.id, + { + capability = "thermostatMode", + component = "main", + command = "setThermostatMode", + args = { "heat" } + } }) + + local hi32 = 0xFFFFFFFF + local lo32 = (0xFFFFFFFF & 0x0FFFFFFF) | (0x1 << 28) + lo32 = (lo32 & 0xF0FFFFFF) | (0x0 << 24) + expect_ac_code_send(hi32, lo32) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.thermostatMode.thermostatMode("heat"))) + + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanOscillationMode.fanOscillationMode("fixed"))) + test.socket.capability:__expect_send(mock_device:generate_test_message("main", + capabilities.fanMode.fanMode("high"))) + + local r_hi = 0xFFFFFFFF + local r_lo = 0xFFFFFFFF + r_lo = (r_lo & 0xFF0FFFFF) | (0x2 << 20) + r_lo = (r_lo & 0xFFFCFFFF) | (0x1 << 16) + expect_ac_code_send(r_hi, r_lo) + end +) + +-- NOTE: The `if args.old_st_store.preferences == nil then return end` early +-- return in info_changed is defensive code that only fires on a brand-new +-- device. It cannot be reliably reproduced in the SmartThings integration +-- test framework because the mock_device is always built from the profile's +-- preference section (st_store.preferences is always a table), and manually +-- constructing a lifecycle event with preferences=nil is normalized away by +-- the lifecycle dispatcher before the handler sees it. + +test.run_registered_tests() From 3967f9153412e7bb5c9df55c8394e6ee4f9da41b Mon Sep 17 00:00:00 2001 From: seojune Date: Fri, 8 May 2026 16:17:59 +0900 Subject: [PATCH 2/2] add refresh capability and testcase --- .../profiles/aqara-bath-heater.yml | 1 + drivers/Aqara/aqara-bath-heater/src/init.lua | 10 ++++++++++ .../src/test/test_aqara_bath_heater.lua | 17 +++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml b/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml index 0002dae8f9..f0baafc78a 100644 --- a/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml +++ b/drivers/Aqara/aqara-bath-heater/profiles/aqara-bath-heater.yml @@ -40,6 +40,7 @@ components: - "low" - "medium" - "high" + - id: refresh categories: - name: Thermostat deviceConfig: diff --git a/drivers/Aqara/aqara-bath-heater/src/init.lua b/drivers/Aqara/aqara-bath-heater/src/init.lua index 78e377e0e1..269a88cb69 100644 --- a/drivers/Aqara/aqara-bath-heater/src/init.lua +++ b/drivers/Aqara/aqara-bath-heater/src/init.lua @@ -324,6 +324,12 @@ local function handle_fan_mode(driver, device, cmd) send_ac_code(device, { fan = fan }) end +local function handle_refresh(driver, device, cmd) + device:send(OnOff.attributes.OnOff:read(device)) + device:send(Level.attributes.CurrentLevel:read(device)) + device:send(ColorControl.attributes.ColorTemperatureMireds:read(device)) +end + -- Zigbee attribute handlers local function on_off_attr_handler(driver, device, value, zb_rx) device:emit_event(capabilities.switch.switch(value.value and "on" or "off")) @@ -538,6 +544,7 @@ local aqara_bathroom_heater_driver = ZigbeeDriver("aqara-bathroom-heater-t1", { capabilities.thermostatHeatingSetpoint, capabilities.fanOscillationMode, capabilities.fanMode, + capabilities.refresh, }, capability_handlers = { @@ -563,6 +570,9 @@ local aqara_bathroom_heater_driver = ZigbeeDriver("aqara-bathroom-heater-t1", { [capabilities.fanMode.ID] = { [capabilities.fanMode.commands.setFanMode.NAME] = handle_fan_mode, }, + [capabilities.refresh.ID] = { + [capabilities.refresh.commands.refresh.NAME] = handle_refresh, + }, }, zigbee_handlers = { diff --git a/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua b/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua index 66a97da8e3..38efd72a73 100644 --- a/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua +++ b/drivers/Aqara/aqara-bath-heater/src/test/test_aqara_bath_heater.lua @@ -521,6 +521,23 @@ test.register_coroutine_test( -- FAN_MID` fallback, but the profile's enabledValues restricts fanMode to -- {"low","medium","high"}, so "auto" is rejected by framework validation. +-- refresh --------------------------------------------------------------------- + +test.register_coroutine_test( + "Capability refresh should read OnOff, Level and ColorTemperature", + function() + test.socket.capability:__queue_receive({ mock_device.id, + { capability = "refresh", component = "main", command = "refresh", args = {} } }) + + test.socket.zigbee:__expect_send({ mock_device.id, + OnOff.attributes.OnOff:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + Level.attributes.CurrentLevel:read(mock_device) }) + test.socket.zigbee:__expect_send({ mock_device.id, + ColorControl.attributes.ColorTemperatureMireds:read(mock_device) }) + end +) + -- ============================================================================ -- 2. ZIGBEE ATTRIBUTE HANDLERS -- ============================================================================