/**
* CentraLite Pearl Thermostat (Custom)
* Device Driver for Hubitat Elevation
*
* Purpose:
* Custom driver for CentraLite Pearl Zigbee Thermostat featuring heating/cooling setpoint
* controls, custom fan circulation cycle loop, battery/power source parsing, and custom health tracking.
*
* ARCHITECTURAL NOTE - AUTO MODE & THERMOSTAT CONTROLLER:
* 'auto' mode is explicitly exposed in supportedThermostatModes to allow the driver to function
* as a full dual-setpoint thermostat controller. This enables Hubitat Dashboard tiles and external
* application controllers (such as MEM) to present and orchestrate dual heating/cooling setpoints,
* automatically issuing system commands across heating, cooling, and idle states as ambient
* conditions require.
*
* Temperature Calibration Architecture:
* - Temperature offset adjustments are applied strictly at the driver layer via hardware calibration
* attribute 0x0201:0x0010. Downstream applications (such as MEM) consume the calibrated 'temperature'
* attribute directly without applying secondary offsets.
*
* Notes:
* Custom Health Check Implementation
* - Intentionally NOT using Hubitat's native 'Health Check' capability.
* - Uses active ZCL attribute reading to verify online presence with phase-anchored scheduling.
**/
/**
* Copyright 2026 James Shimota
*
* 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.
**/
/**
* Changelog:
* v0.7.8 09/14/26 jshimota Optimized markHealthCheckSuccess to emit isStateChange: false to refresh lastActivity without event history log clutter.
* v0.7.7 09/11/26 jshimota Hardened Health Check state engine: isolated health validation to cluster 0x0000:0x0007, unscheduled stale command timeouts on initialize/execute, and updated getTemperature BigDecimal initialization.
* v0.7.6 09/11/26 jshimota Purged all internal ping nomenclature and fully synchronized Health Check architecture with Driver Template v1.0.11 / RGB Bulb driver standards.
* v0.7.5 09/11/26 jshimota Forced healthStatus sendEvent with isStateChange: true in parse() to ensure Hubitat updates platform lastActivity timestamp.
* v0.7.4 09/08/26 jshimota Refined lockDashboardToAuto execution path and updated attribute mapping for dual-setpoint MEM controller support.
* v0.7.3 09/08/26 jshimota Restructured setThermostatMode and auto() fallback handling to align with physicalThermostatMode tracking.
* v0.7.2 09/01/26 jshimota Updated header notice to accurately reflect auto mode implementation for Dashboard and MEM thermostat controller functionality.
* v0.7.1 09/01/26 jshimota Documented 'auto' mode architecture for external manager (MEM) orchestration in header comments and method stubs.
* v0.7.0 09/01/26 jshimota Exposed 'auto' mode in supportedThermostatModes and enabled physical auto() command for external manager (MEM) orchestration.
* v0.6.9 08/31/26 jshimota Added 'tempPrecision' preference strictly isolated to device ambient temperature parsing (0, 1, or 2 decimals). Setpoints remain locked to whole integers.
* v0.6.8 08/31/26 jshimota Standardized log and descriptionText unit formatting across temperature (°F/°C) and percentage (%) attributes to ' [Value] [Unit]'.
* v0.6.7 08/31/26 jshimota Fixed case-sensitive heatingSetpoint attribute check bug in changeSetpoint() fallback logic.
* v0.6.6 08/31/26 jshimota Separated physical thermostatFanMode (on/auto/off/circulate) from thermostatFanControlSource (local/external). Documented driver-level temperature calibration contract.
* v0.6.5 08/31/26 jshimota Hardened 'ext' fan control mode. Preserved 'ext' attribute state while permitting physical fanOn() and fanAuto() Zigbee frame execution from external applications.
* v0.6.4 08/31/26 jshimota Removed enableFanCommands preference switch and fanModeNote operational note. Streamlined fan mode bypass to check for 'ext' state.
* v0.6.3 08/31/26 jshimota Added 'ext' option to supportedThermostatFanModes JSON array and GUI command dropdown. Updated command execution logic and GUI notes.
* v0.6.2 08/31/26 jshimota Refined enableFanCommands preference description text.
* v0.6.1 08/31/26 jshimota Added explicit GUI parameter descriptions to Fan commands notifying user that fan mode commands are only functional when enabled in preferences.
* v0.6.0 08/31/26 jshimota Added enableFanCommands preference switch to bypass physical fan cluster execution when managed externally (e.g., via MEM app). Updated GUI preferences guidance text.
* v0.5.0 08/31/26 jshimota Applied Driver Template v1.0.10: Standardized logging engine, phase-anchored custom Health Check, single-shot version demarcation, master utility routines, and updated GUI controls.
**/
static String version() { return '0.7.8' }
def timeStamp() { return "2026/09/14 10:35 AM" }
import hubitat.zigbee.zcl.DataType
import groovy.transform.Field
import java.math.RoundingMode
metadata {
definition(
name: "CentraLite Pearl Thermostat (Custom)",
namespace: "jshimota",
author: "James Shimota",
importUrl: "https://raw.githubusercontent.com/jshimota01/hubitat/main/Drivers/centralite_pearl_thermostat/centralite_pearl_thermostat_custom.groovy"
) {
capability "Actuator"
capability "Battery"
capability "Configuration"
capability "Refresh"
capability "Sensor"
capability "TemperatureMeasurement"
capability "Thermostat"
// Commands
command "Health Check"
command "fanOff"
command "initialize"
command "lowerCoolingSetpointLevel"
command "lowerHeatingSetpointLevel"
command "raiseCoolingSetpointLevel"
command "raiseHeatingSetpointLevel"
command "resetDriver"
command "toggleHoldMode"
command "setCoolingSetpoint", [[name: "degrees", type: "NUMBER", description: "Cooling Setpoint in degrees"]]
command "setHeatingSetpoint", [[name: "degrees", type: "NUMBER", description: "Heating Setpoint in degrees"]]
command "setThermostatFanControlSource", [[name: "source*", type: "ENUM", description: "Select 'external' when fan circulation is managed by an external application like MEM.", constraints: ["local", "external"]]]
// Attributes
attribute "healthStatus", "enum", ["unknown", "offline", "online"]
attribute "powerSource", "string"
attribute "supportedThermostatModes", "JSON_OBJECT"
attribute "supportedThermostatFanModes", "JSON_OBJECT"
attribute "thermostatFanCycleState", "enum", ["on", "off"]
attribute "thermostatFanControlSource", "enum", ["local", "external"]
attribute "thermostatFanModes", "JSON_OBJECT"
attribute "thermostatHoldMode", "string"
attribute "thermostatRunMode", "string"
attribute "physicalThermostatMode", "string"
fingerprint profileId: "0104", inClusters: "0000,0001,0003,0020,0201,0202,0204,0B05", outClusters: "000A,0019", manufacturer: "Centralite", model: "3157100", deviceJoinName: "CentraLite Pearl Thermostat (Custom)"
}
preferences {
input name: "lockDashboardToAuto", type: "bool", title: "Lock Dashboard Tile to 'Auto' Mode", description: "Note: This setting strictly forces the 'thermostatMode' attribute to stay in 'auto' so Hubitat Dashboard tiles preserve the dual-setpoint view. Physical mode commands (heat, cool, off) are tracked independently via 'physicalThermostatMode'.", defaultValue: true, required: true
input name: "tempOffset", type: "decimal", title: "Temperature Offset", description: "Adjust temperature readings by -4.5 to +4.5 degrees. (Applied at driver layer).", defaultValue: 0.0, range: "-4.5..4.5"
input name: "tempPrecision", type: "enum", title: "Device Temperature Display Precision", options: ["0": "0 Decimals (Whole Number - e.g., 72)", "1": "1 Decimal (e.g., 71.8)", "2": "2 Decimals (e.g., 71.82)"], defaultValue: "1", description: "Controls decimal precision for reported device ambient temperature attribute only."
input name: "HealthCheckInterval", type: "enum", title: "Health Check Interval", options: HealthCheckIntervalOpts.options, defaultValue: HealthCheckIntervalOpts.defaultValue, description: "Changes how often the driver executes a Health Check to verify device online status.
Note: This is a custom driver routine and is NOT the native Hubitat Elevation platform Health Check service."
// Independent Logging Switches
input name: "logInfoEnable", type: "bool", title: "Logging - Enable Info Logging", defaultValue: true, required: true
input name: "logErrorEnable", type: "bool", title: "Logging - Enable Error Logging", defaultValue: true, required: true
input name: "logWarnEnable", type: "bool", title: "Logging - Enable Warning Logging", defaultValue: true, required: true
input name: "logDebugEnable", type: "bool", title: "Logging - Enable Debug Logging", defaultValue: false, required: true
input name: "logTraceEnable", type: "bool", title: "Logging - Enable Trace Logging", defaultValue: false, required: true
}
}
private void checkAndLogVersionDemarcation() {
String currentVer = version()
if (state.driverVersion != currentVer) {
logTrace "=================== DRIVER VERSION UPDATE: v${currentVer} (${timeStamp()}) ==================="
state.driverVersion = currentVer
}
}
/* =========================================================================================
HUBITAT LIFECYCLE ROUTINES
========================================================================================= */
void installed() {
checkAndLogVersionDemarcation()
logInfo "Installing CentraLite Pearl Thermostat (Custom) v${version()} (${timeStamp()})..."
initializeHealthCheckPhase()
sendEvent(name: "healthStatus", value: "unknown")
initialize(true)
}
void updated() {
checkAndLogVersionDemarcation()
logInfo "Preferences updated"
initialize(false)
runIn(1, "configure")
}
void initialize(Boolean isInstall = false) {
checkAndLogVersionDemarcation()
unschedule("disableDebugLogging")
unschedule("deviceCommandTimeout")
state.healthCheckPending = false
if (device.currentValue("healthStatus") == null) sendEvent(name: "healthStatus", value: "unknown")
def fanOptionsList = ["on", "auto", "off", "circulate"]
sendEvent(name: "thermostatFanModes", value: groovy.json.JsonOutput.toJson(fanOptionsList))
sendEvent(name: "supportedThermostatFanModes", value: groovy.json.JsonOutput.toJson(fanOptionsList))
def systemModesList = ["off", "heat", "cool", "auto", "emergencyHeat"]
sendEvent(name: "supportedThermostatModes", value: groovy.json.JsonOutput.toJson(systemModesList))
if (device.currentValue("thermostatFanControlSource") == null) sendEvent(name: "thermostatFanControlSource", value: "local")
if (device.currentValue("battery") == null) sendEvent(name: "battery", value: 100, unit: "%")
if (device.currentValue("powerSource") == null) sendEvent(name: "powerSource", value: "unknown")
if (device.currentValue("thermostatOperatingState") == null) sendEvent(name: "thermostatOperatingState", value: "idle")
if (device.currentValue("temperature") == null) sendEvent(name: "temperature", value: 70, unit: getTemperatureScale())
if (device.currentValue("heatingSetpoint") == null) sendEvent(name: "heatingSetpoint", value: 68, unit: getTemperatureScale())
if (device.currentValue("coolingSetpoint") == null) sendEvent(name: "coolingSetpoint", value: 74, unit: getTemperatureScale())
if (device.currentValue("thermostatSetpoint") == null) sendEvent(name: "thermostatSetpoint", value: 68, unit: getTemperatureScale())
if (getSettingBool("lockDashboardToAuto", true)) {
updateAttribute("thermostatMode", "auto")
} else if (device.currentValue("thermostatMode") == null) {
sendEvent(name: "thermostatMode", value: "off")
}
final int interval = settings?.HealthCheckInterval != null ? settings.HealthCheckInterval.toInteger() : 480
if (interval > 0) scheduleHealthCheck("executeHealthCheckScheduled", interval) else unschedule("executeHealthCheckScheduled")
if (isInstall) {
device.updateSetting("logDebugEnable", [type: "bool", value: true])
logInfo "Debug logging enabled for 30 minutes."
runIn(1800, "disableDebugLogging")
} else if (getSettingBool("logDebugEnable", false)) {
logInfo "Debug logging active. Automatic turn-off scheduled."
runIn(1800, "disableDebugLogging", [overwrite: false])
} else {
unschedule("disableDebugLogging")
}
runIn(2, "refresh")
}
List configure() {
checkAndLogVersionDemarcation()
logInfo "Configuring device reporting and options..."
List cmds = zigbee.batteryConfig() +
zigbee.configureReporting(0x0201, 0x0000, DataType.INT16, 10, 600, 50) +
zigbee.configureReporting(0x0201, 0x0011, DataType.INT16, 5, 300, 50) +
zigbee.configureReporting(0x0201, 0x0012, DataType.INT16, 5, 300, 50) +
zigbee.configureReporting(0x0201, 0x001C, DataType.ENUM8, 5, 300, 1) +
zigbee.configureReporting(0x0201, 0x0029, DataType.BITMAP16, 5, 300, 1) +
zigbee.configureReporting(0x0201, 0x0023, DataType.ENUM8, 5, 300, 1) +
zigbee.configureReporting(0x0202, 0x0000, DataType.ENUM8, 5, 300, 1)
if (settings?.tempOffset != null) {
BigDecimal offset = settings.tempOffset as BigDecimal
double celsiusOffset = (getTemperatureScale() == "C") ? offset.doubleValue() : (offset.doubleValue() / 1.8)
int rawOffset = Math.round(celsiusOffset * 10).toInteger()
cmds += zigbee.writeAttribute(0x0201, 0x0010, DataType.INT8, rawOffset)
}
cmds += executeHealthCheck()
return cmds
}
List refresh() {
logDebug "Executing refresh()..."
return zigbee.readAttribute(0x0000, 0x0007) +
zigbee.readAttribute(0x0201, 0x0000) +
zigbee.readAttribute(0x0201, 0x0010) +
zigbee.readAttribute(0x0201, 0x0011) +
zigbee.readAttribute(0x0201, 0x0012) +
zigbee.readAttribute(0x0201, 0x001C) +
zigbee.readAttribute(0x0201, 0x001E) +
zigbee.readAttribute(0x0201, 0x0023) +
zigbee.readAttribute(0x0201, 0x0029) +
zigbee.readAttribute(0x0001, 0x0020) +
zigbee.readAttribute(0x0202, 0x0000)
}
/* =========================================================================================
COMMAND IMPLEMENTATIONS
========================================================================================= */
void setThermostatFanControlSource(String value) {
if (value == null) return
String norm = value.toLowerCase().trim()
if (norm in ["local", "external"]) {
logInfo "Thermostat Fan Control Source set to '${norm}'"
updateAttribute("thermostatFanControlSource", norm)
if (norm == "external") unschedule("runCirculateCycle")
} else {
logError "Invalid fan control source requested: ${value}"
}
}
void raiseHeatingSetpointLevel() { changeSetpoint("heatingSetpoint", 1) }
void lowerHeatingSetpointLevel() { changeSetpoint("heatingSetpoint", -1) }
void raiseCoolingSetpointLevel() { changeSetpoint("coolingSetpoint", 1) }
void lowerCoolingSetpointLevel() { changeSetpoint("coolingSetpoint", -1) }
private void changeSetpoint(String attributeName, int delta) {
def currentVal = device.currentValue(attributeName) ?: (attributeName == "heatingSetpoint" ? 68 : 74)
int nextLevel = currentVal.toInteger() + delta
if (attributeName == "heatingSetpoint") setHeatingSetpoint(nextLevel) else setCoolingSetpoint(nextLevel)
}
void toggleHoldMode() {
String currentHoldMode = device.currentValue("thermostatHoldMode") ?: "holdOff"
if (currentHoldMode == "holdOn") holdOff() else holdOn()
}
void setThermostatMode(String value) {
if (value == null) return
logDebug "setThermostatMode requested: ${value}"
String normalizedValue = value.toLowerCase().replaceAll(/\s+(.)/) { match, group -> group.toUpperCase() }
if (normalizedValue == "emergencyheat") normalizedValue = "emergencyHeat"
switch(normalizedValue) {
case "off": off(); break
case "cool": cool(); break
case "heat": heat(); break
case "emergencyHeat": emergencyHeat(); break
case "auto": auto(); break
default:
logError "Unsupported thermostat mode requested: ${value}"
break
}
}
void setThermostatFanMode(String value) {
if (value == null) return
String normValue = value.toLowerCase().trim()
if (normValue in ["fanon", "on"]) fanOn()
else if (normValue in ["fanauto", "auto"]) fanAuto()
else if (normValue in ["fanoff", "off"]) fanOff()
else if (normValue in ["fancirculate", "circulate"]) fanCirculate()
else logError "Unsupported fan mode requested: ${value}"
}
void setThermostatHoldMode(String value) {
if (value == null) return
String norm = value.toLowerCase().trim()
if (norm in ["holdon", "on", "true"]) holdOn()
else if (norm in ["holdoff", "off", "false"]) holdOff()
else logError "Invalid hold mode requested: ${value}"
}
List off() {
logInfo "Setting physical thermostat mode to Off"
updateModeState("off")
return zigbee.writeAttribute(0x0201, 0x1C, DataType.ENUM8, 0)
}
List cool() {
logInfo "Setting physical thermostat mode to Cool"
updateModeState("cool")
return zigbee.writeAttribute(0x0201, 0x1C, DataType.ENUM8, 3)
}
List heat() {
logInfo "Setting physical thermostat mode to Heat"
updateModeState("heat")
return zigbee.writeAttribute(0x0201, 0x1C, DataType.ENUM8, 4)
}
List emergencyHeat() {
logInfo "Setting physical thermostat mode to Emergency Heat"
updateModeState("emergencyHeat")
return zigbee.writeAttribute(0x0201, 0x1C, DataType.ENUM8, 5)
}
List auto() {
logInfo "Setting thermostat mode to Auto"
updateModeState("auto")
def temp = device.currentValue("temperature") ?: 70
def hSp = device.currentValue("heatingSetpoint") ?: 68
def cSp = device.currentValue("coolingSetpoint") ?: 74
def targetSp = (temp >= cSp) ? cSp : hSp
updateAttribute("thermostatSetpoint", targetSp, getTemperatureScale(), "digital")
return zigbee.writeAttribute(0x0201, 0x1C, DataType.ENUM8, 1)
}
private void updateModeState(String newMode) {
updateAttribute("physicalThermostatMode", newMode)
if (getSettingBool("lockDashboardToAuto", true)) {
updateAttribute("thermostatMode", "auto")
} else {
updateAttribute("thermostatMode", newMode)
}
}
List on() { return fanOn() }
List fanOn() {
logInfo "Executing physical fanOn command"
unschedule("runCirculateCycle")
updateAttribute("thermostatFanMode", "on", null, "digital")
return zigbee.writeAttribute(0x0202, 0x00, DataType.ENUM8, 4)
}
List fanAuto() {
logInfo "Executing physical fanAuto command"
unschedule("runCirculateCycle")
updateAttribute("thermostatFanMode", "auto", null, "digital")
return zigbee.writeAttribute(0x0202, 0x00, DataType.ENUM8, 5)
}
List fanOff() {
logInfo "Executing physical fanOff command"
unschedule("runCirculateCycle")
updateAttribute("thermostatFanMode", "off", null, "digital")
return zigbee.writeAttribute(0x0202, 0x00, DataType.ENUM8, 0)
}
void fanCirculate() {
if (device.currentValue("thermostatFanControlSource") == "external") {
logInfo "fanCirculate command ignored (Control Source is set to 'external')."
return
}
logInfo "Setting thermostat fan mode to Circulate (30m Loop)"
unschedule("runCirculateCycle")
updateAttribute("thermostatFanMode", "circulate", null, "digital")
runCirculateCycle([targetState: "on"])
}
void runCirculateCycle(Map data = [:]) {
if (device.currentValue("thermostatFanControlSource") == "external" || device.currentValue("thermostatFanMode") != "circulate") return
String targetState = data?.targetState
if (targetState == null) {
String lastCycleState = device.currentValue("thermostatFanCycleState") ?: "off"
targetState = (lastCycleState == "on") ? "off" : "on"
}
sendEvent(name: "thermostatFanCycleState", value: targetState, isStateChange: true, displayed: false)
if (targetState == "on") {
logDebug "Circulation Loop: Turning Fan ON"
sendHubCommand(new hubitat.device.HubAction(zigbee.writeAttribute(0x0202, 0x00, DataType.ENUM8, 4)[0], hubitat.device.Protocol.ZIGBEE))
runIn(1800, "runCirculateCycle", [overwrite: true, data: [targetState: "off"]])
} else {
logDebug "Circulation Loop: Setting Fan to AUTO"
sendHubCommand(new hubitat.device.HubAction(zigbee.writeAttribute(0x0202, 0x00, DataType.ENUM8, 5)[0], hubitat.device.Protocol.ZIGBEE))
runIn(1800, "runCirculateCycle", [overwrite: true, data: [targetState: "on"]])
}
}
List holdOn() {
logInfo "Setting Hold Mode to On"
updateAttribute("thermostatHoldMode", "holdOn")
return zigbee.writeAttribute(0x0201, 0x23, DataType.ENUM8, 1)
}
List holdOff() {
logInfo "Setting Hold Mode to Off"
updateAttribute("thermostatHoldMode", "holdOff")
return zigbee.writeAttribute(0x0201, 0x23, DataType.ENUM8, 0)
}
List setHeatingSetpoint(degrees) {
return processSetpoint(degrees, 0x12)
}
List setCoolingSetpoint(degrees) {
return processSetpoint(degrees, 0x11)
}
private List processSetpoint(degrees, int attributeId) {
if (degrees == null || !degrees.toString().isNumber()) return []
boolean isC = (getTemperatureScale() == "C")
int maxTemp = isC ? 44 : 86
int minTemp = isC ? 7 : 30
BigDecimal numDegrees = new BigDecimal(degrees.toString())
int degreesInteger = Math.round(numDegrees).toInteger()
degreesInteger = Math.max(minTemp, Math.min(degreesInteger, maxTemp))
double celsius = isC ? degreesInteger : fahrenheitToCelsius(degreesInteger)
int finalValue = Math.round(celsius * 100).toInteger()
String attrName = (attributeId == 0x12) ? "heatingSetpoint" : "coolingSetpoint"
logInfo "Setting ${attrName} to ${degreesInteger} °${getTemperatureScale()}"
updateAttribute(attrName, degreesInteger, getTemperatureScale(), "digital")
updateAttribute("thermostatSetpoint", degreesInteger, getTemperatureScale(), "digital")
if (attrName == "heatingSetpoint" && device.currentValue("coolingSetpoint") == null) {
updateAttribute("coolingSetpoint", (degreesInteger + 5), getTemperatureScale(), "digital")
} else if (attrName == "coolingSetpoint" && device.currentValue("heatingSetpoint") == null) {
updateAttribute("heatingSetpoint", (degreesInteger - 5), getTemperatureScale(), "digital")
}
return zigbee.writeAttribute(0x0201, attributeId, DataType.INT16, finalValue)
}
/* =========================================================================================
HEALTH CHECK ROUTINE TEMPLATE
========================================================================================= */
void "Health Check"() {
List cmds = executeHealthCheck()
if (cmds) sendHubCommand(new hubitat.device.HubMultiAction(cmds, hubitat.device.Protocol.ZIGBEE))
}
void executeHealthCheckScheduled() {
logTrace "executeHealthCheckScheduled(): Triggering scheduled health check..."
List cmds = executeHealthCheck()
if (cmds) sendHubCommand(new hubitat.device.HubMultiAction(cmds, hubitat.device.Protocol.ZIGBEE))
}
private List executeHealthCheck() {
logInfo "Executing Health Check..."
unschedule("deviceCommandTimeout")
state.healthCheckPending = true
scheduleCommandTimeoutCheck()
return zigbee.readAttribute(0x0000, 0x0007)
}
private void initializeHealthCheckPhase() {
if (state.healthCheckStartHour == null) state.healthCheckStartHour = new Random().nextInt(24)
if (state.healthCheckStartMinute == null) state.healthCheckStartMinute = new Random().nextInt(60)
}
private void scheduleHealthCheck(String methodToSchedule, int intervalMin) {
unschedule(methodToSchedule)
initializeHealthCheckPhase()
final int h = state.healthCheckStartHour as Integer
final int m = state.healthCheckStartMinute as Integer
logInfo "Scheduling Health Check every ${intervalMin} minutes anchored at ${String.format('%02d:%02d', h, m)} daily"
switch (intervalMin) {
case 60: schedule("0 ${m} * ? * * *", methodToSchedule); break
case 240: schedule("0 ${m} ${[0,4,8,12,16,20].collect{(it+h)%24}.sort().join(',')} ? * * *", methodToSchedule); break
case 480: schedule("0 ${m} ${[0,8,16].collect{(it+h)%24}.sort().join(',')} ? * * *", methodToSchedule); break
case 720: schedule("0 ${m} ${[0,12].collect{(it+h)%24}.sort().join(',')} ? * * *", methodToSchedule); break
case 1440: schedule("0 ${m} ${h} ? * * *", methodToSchedule); break
default: schedule("0 */${intervalMin} * ? * * *", methodToSchedule); break
}
}
private void scheduleCommandTimeoutCheck(final int delay = COMMAND_TIMEOUT) {
runIn(delay, "deviceCommandTimeout", [overwrite: true])
}
void deviceCommandTimeout() {
logWarn "Health Check failed: No response received within ${COMMAND_TIMEOUT} seconds (device offline?)"
state.healthCheckPending = false
updateAttribute("healthStatus", "offline")
}
/* =========================================================================================
ZIGBEE MESSAGE PARSING
========================================================================================= */
void parse(String description) {
logDebug "Parsing raw description -> ${description}"
if (!description) return
try {
if (description.startsWith("read attr -") || description.startsWith("catchall:")) {
Map descMap = zigbee.parseDescriptionAsMap(description)
if (!descMap) return
Integer clusterInt = descMap.cluster ? Integer.parseInt(descMap.cluster, 16) : descMap.clusterInt
Integer attrInt = descMap.attrId ? Integer.parseInt(descMap.attrId, 16) : descMap.attrInt
String clusterHex = descMap.cluster ?: (clusterInt != null ? zigbee.convertToHexString(clusterInt, 4) : "unknown")
markDeviceActivity()
switch(clusterInt) {
case 0x0201: // Thermostat Cluster
if (attrInt == 0x0000) {
BigDecimal temp = getTemperature(descMap.value)
updateAttribute("temperature", temp, getTemperatureScale(), "physical")
} else if (attrInt == 0x0011) {
BigDecimal temp = getTemperature(descMap.value)
int wholeTemp = Math.round(temp).toInteger()
updateAttribute("coolingSetpoint", wholeTemp, getTemperatureScale(), "physical")
updateAttribute("thermostatSetpoint", wholeTemp, getTemperatureScale(), "physical")
} else if (attrInt == 0x0012) {
BigDecimal temp = getTemperature(descMap.value)
int wholeTemp = Math.round(temp).toInteger()
updateAttribute("heatingSetpoint", wholeTemp, getTemperatureScale(), "physical")
updateAttribute("thermostatSetpoint", wholeTemp, getTemperatureScale(), "physical")
} else if (attrInt == 0x001C) {
String mode = getModeMap()[descMap.value] ?: "off"
updateAttribute("physicalThermostatMode", mode, null, "physical")
if (!getSettingBool("lockDashboardToAuto", true)) {
updateAttribute("thermostatMode", mode, null, "physical")
}
} else if (attrInt == 0x001E) {
String runMode = getModeMap()[descMap.value] ?: "off"
updateAttribute("thermostatRunMode", runMode, null, "physical")
} else if (attrInt == 0x0023) {
String holdMode = getHoldModeMap()[descMap.value] ?: "holdOff"
updateAttribute("thermostatHoldMode", holdMode, null, "physical")
} else if (attrInt == 0x0029) {
String opState = getThermostatOperatingStateMap()[descMap.value] ?: "idle"
updateAttribute("thermostatOperatingState", opState, null, "physical")
}
break
case 0x0202: // Fan Control Cluster
if (attrInt == 0x0000) {
if (device.currentValue("thermostatFanMode") != "circulate") {
String fanMode = getFanModeMap()[descMap.value] ?: "auto"
updateAttribute("thermostatFanMode", fanMode, null, "physical")
}
}
break
case 0x0001: // Power Configuration Cluster
if (attrInt == 0x0020) {
Integer batLevel = getBatteryLevel(descMap.value)
updateAttribute("battery", batLevel, "%", "physical")
}
break
case 0x0000: // Basic Cluster
if (attrInt == 0x0007) {
markHealthCheckSuccess(clusterHex)
String source = getPowerSource()[descMap.value] ?: "unknown"
updateAttribute("powerSource", source, null, "physical")
}
break
}
}
} catch (Exception e) {
logError "Error parsing description frame [${description}]: ${e.message}"
}
}
private void markHealthCheckSuccess(String clusterHex = "0000") {
if (state.healthCheckPending == true) {
logInfo "Valid Health Check response verified on cluster 0x${clusterHex}"
state.healthCheckPending = false
unschedule("deviceCommandTimeout")
}
sendEvent(
name: "healthStatus",
value: "online",
isStateChange: false,
descriptionText: "${device.displayName} health check verified online"
)
}
private void markDeviceActivity() {
if (device.currentValue("healthStatus") == "offline") {
sendEvent(
name: "healthStatus",
value: "online",
isStateChange: false,
descriptionText: "${device.displayName} healthStatus restored to online via active traffic"
)
}
}
private Map getModeMap() { ["00":"off", "01":"auto", "03":"cool", "04":"heat", "05":"emergencyHeat", "06":"precooling", "07":"fan only", "08":"dry", "09":"sleep"] }
private Map getHoldModeMap() { ["00":"holdOff", "01":"holdOn"] }
private Map getPowerSource() { ["01":"24VAC", "03":"Battery", "81":"24VAC"] }
private Map getFanModeMap() { ["00":"off", "04":"on", "05":"auto"] }
private Map getThermostatOperatingStateMap() {
["0000":"idle", "0001":"heating", "0002":"cooling", "0004":"fan only", "0005":"heating", "0006":"cooling", "0008":"heating", "0009":"heating", "000A":"heating", "000D":"heating", "0010":"cooling", "0012":"cooling", "0014":"cooling", "0015":"cooling"]
}
private BigDecimal getTemperature(String value) {
if (value == null) return null
int raw = Integer.parseInt(value, 16)
if (raw > 0x7FFF) raw -= 0x10000
double celsius = raw / 100.0
double tempVal = (getTemperatureScale() == "C") ? celsius : celsiusToFahrenheit(celsius)
int precision = settings?.tempPrecision != null ? settings.tempPrecision.toInteger() : 1
return BigDecimal.valueOf(tempVal).setScale(precision, RoundingMode.HALF_UP)
}
private Integer getBatteryLevel(String rawValue) {
if (rawValue == null) return null
int intValue = Integer.parseInt(rawValue, 16)
int min = 21
int max = 30
int pct = Math.round(((intValue - min) * 100.0) / (max - min)) as int
return Math.max(0, Math.min(pct, 100))
}
/* =========================================================================================
MASTER UTILITY ROUTINES & LOGGING ENGINE
========================================================================================= */
void resetDriver() {
logInfo "Starting full driver reset..."
Object savedHour = state.healthCheckStartHour
Object savedMinute = state.healthCheckStartMinute
clearAllSchedules()
clearAllAttributes()
clearAllDriverStates()
if (savedHour != null) state.healthCheckStartHour = savedHour
if (savedMinute != null) state.healthCheckStartMinute = savedMinute
initialize(false)
logInfo "Driver reset process completed and re-initialized."
}
void clearAllDriverStates() {
logInfo "Clearing all driver states..."
state.clear()
logInfo "All states have been cleared."
}
void clearAllAttributes() {
logInfo "Clearing all attributes..."
device.properties.supportedAttributes.each { device.deleteCurrentState("$it") }
logInfo "All attributes have been cleared."
}
void clearAllSchedules() {
logInfo "Clearing all scheduled jobs (including orphaned schedules)..."
unschedule()
logInfo "All scheduled jobs have been successfully cleared."
}
private void updateAttribute(final String attribute, final Object value, final String unit = null, final String type = null) {
final String currentVal = device.currentValue(attribute)?.toString()
if (currentVal == value?.toString()) return
String formattedUnit = ""
if (unit) {
if (unit in ["F", "C"]) formattedUnit = " °${unit}"
else if (unit.startsWith("°")) formattedUnit = " ${unit}"
else formattedUnit = " ${unit}"
}
final String descriptionText = "${device.displayName} - ${attribute} was set to ${value}${formattedUnit}"
logInfo descriptionText
sendEvent(name: attribute, value: value, unit: unit, type: type, descriptionText: descriptionText)
}
void disableDebugLogging() {
if (getSettingBool("logDebugEnable", false)) {
logWarn "30 minutes have elapsed. Automatically disabling debug logging."
device.updateSetting("logDebugEnable", [type: "bool", value: false])
}
}
private void logMessage(String level, String msg) {
String lowerLevel = level?.toLowerCase() ?: "info"
String devName = device.displayName ?: "Device Driver"
String settingKey
switch (lowerLevel) {
case "info": settingKey = "logInfoEnable"; break
case "error": settingKey = "logErrorEnable"; break
case "warn": settingKey = "logWarnEnable"; break
case "debug": settingKey = "logDebugEnable"; break
case "trace": settingKey = "logTraceEnable"; break
default: settingKey = "logInfoEnable"; break
}
Boolean defaultEnabled = (lowerLevel in ["info", "warn", "error"])
if (getSettingBool(settingKey, defaultEnabled)) {
log."${lowerLevel}" "${devName}: ${msg}"
}
}
private void logInfo(String msg) { logMessage("info", msg) }
private void logDebug(String msg) { logMessage("debug", msg) }
private void logTrace(String msg) { logMessage("trace", msg) }
private void logWarn(String msg) { logMessage("warn", msg) }
private void logError(String msg) { logMessage("error", msg) }
private Boolean getSettingBool(String key, Boolean defaultVal = false) {
return (settings && settings[key] != null) ? settings[key] as Boolean : defaultVal
}
@Field static final Map HealthCheckIntervalOpts = [
defaultValue: 480,
options: [ 60: "Every Hour", 240: "Every 4 Hours", 480: "Every 8 Hours", 720: "Every 12 Hours", 1440: "Every 24 Hours", 0: "Disabled" ]
]
@Field static final int COMMAND_TIMEOUT = 10