/**
* Aqara FP300 Presence Multi-Sensor driver for Hubitat
* Model: PS-S04D (lumi.sensor_occupy.agl8)
*
* Simplified from the original multi-device driver by @kkossev.
* Retains full FP300 functionality; all other device support removed.
* Temperature and Humidity child device removed.
*
* Licensed under the Apache License, Version 2.0
*
* Version Date Who What
* 1.0.0 2026-02-25 Dan Ogorchock First release of streamlined driver code
* 1.0.1 2026-03-01 Dan Ogorchock Improved Temperature, Humidity, and Illuminance Zigbee reporting for battery life
* 1.0.2 2026-03-02 Dan Ogorchock Removed unnecessary configureReporting for Temperature, Humidity, and Illuminance. The FP300 has special custom handling for these already.
* 1.0.3 2026-03-02 Dan Ogorchock Added Import URL
* 1.0.4 2026-03-02 Dan Ogorchock Additional code cleanup
* 1.0.5 2026-03-06 Dan Ogorchock Improved efficiency
* 1.0.6 2026-03-12 Dan Ogorchock Simplified User Preferences logic & improved User Preferences titles and descriptions
* 1.0.7 2026-03-12 Dan Ogorchock Automatically remove state.params variable (thanks @hubitrep!)
* 1.0.8 2026-03-12 Dan Ogorchock Only send Presence Detection Mode setting to the FP300 if the value has changed. If it is changed, Spatial Learning must be run to calibrate the mmWave sensor.
* Removed aiInterferenceIdentification & aiSensitivityAdaptive user preferences, as setting these also messes up the mmWave calibration. Will revisit if they are actually useful.
* 1.0.9 2026-03-12 Dan Ogorchock Replace @Field variables with traditional state variables. @Field variables are reset each time the driver source code is saved, which can lead to unexpected behaviors.
* Added aiInterferenceIdentification & aiSensitivityAdaptive back as Advanced/Experimental user preferences. Only send these 2 setting to the FP300 if the value has changed.
*
*/
static String version() { "1.0.9" }
static String timeStamp() { "2026/03/13 09:44" }
import hubitat.device.Protocol
import groovy.transform.Field
import hubitat.zigbee.zcl.DataType
import java.math.RoundingMode
import java.util.concurrent.ConcurrentHashMap
@Field static final Integer INFO_AUTO_CLEAR_PERIOD = 60
@Field static final Integer COMMAND_TIMEOUT = 10
@Field static final Integer PRESENCE_COUNT_THRESHOLD = 3 //deviceHealthCheck presence count threshold
@Field static final Integer DEFAULT_POLLING_INTERVAL = 21600 //deviceHealthCheck polling interval
metadata {
definition( name: "Aqara FP300 Presence Multi-Sensor", namespace: "ogiewon", author: "Dan Ogorchock", importUrl: "https://raw.githubusercontent.com/ogiewon/Hubitat/refs/heads/master/Drivers/aqara-fp300.src/aqara-fp300.groovy", singleThreaded: true) {
capability "Sensor"
capability "Motion Sensor"
capability "Illuminance Measurement"
capability "Temperature Measurement"
capability "Relative Humidity Measurement"
capability "Battery"
capability "Voltage Measurement"
capability "Health Check"
capability "Refresh"
capability "Initialize"
capability "Configuration"
attribute "_status_", "string"
attribute "healthStatus", "enum", ["unknown", "offline", "online"]
attribute "roomState", "enum", ["unoccupied", "occupied"] // Presence / room state - mmWave sensor status
attribute "pirDetection", "enum", ["active", "inactive"]
attribute "targetDistance", "number"
command "configure", [[name: "Wake the device with one button press.
Re-initialize the device and load defaults by clicking Run"]]
command "ping", [[name: "Wake the device with one button press.
Check device online status and measure RTT by clicking Run"]]
command "restartDevice", [[name: "Wake the device with one button press.
Restart Device by clicking Run"]]
command "startSpatialLearning", [[name: "Wake the device with one button press.
Ensure the room is empty, then click Run to start a 30-second calibration"]]
command "trackTargetDistance", [[name: "Wake the device with one button press.
Start tracking by clicking Run (~3 min reporting)"]]
command "refresh", [[name: "Wake the device with one button press.
Read current parameters from device by clicking Run"]]
fingerprint profileId: "0104", endpointId: "01", inClusters: "0012,0400,0405,0402,0001,0003,0000,FCC0", outClusters: "000A,0019", model: "lumi.sensor_occupy.agl8", manufacturer: "Aqara", controllerType: "ZGB", deviceJoinName: "Aqara FP300 Presence Sensor PS-S04D"
}
preferences {
input name: "txtEnable", type: "bool", title: "Description text logging", description: "Provides informative log data during communications with the FP300 sensor.", defaultValue: true
input name: "logEnable", type: "bool", title: "Debug logging", description: "Provides detailed log data to help debug the driver code.
Will automatically disable itself after 30 minutes.", defaultValue: true
// ── Basic parameters ──────────────────────────────────────────────────
input name: "presenceDetectionMode", type: "enum", title: "Presence Detection Mode", description: "WARNING: Changing this setting will result in losing the mmWave sensor's current calibration. You should re-run Spatial Learning if this setting is changed!
* 'Both mmWave+PIR' is the recommended setting.
* 'PIR only' reveals PIR Detection Interval preference and hides mmWave prefereces.
* 'Both mmWave+PIR' or 'mmWave only' reveals mmWave specific preferences and hides unsued PIR Detection Interval preference.", options: ["both": "Both mmWave+PIR", "mmwave": "mmWave only", "pir": "PIR only"], defaultValue: "both"
if (presenceDetectionMode == "pir") {
input name: "pirDetectionInterval", type: "number", title: "PIR Detection Interval (2-300s)", description: "The interval duration in seconds for triggering infrared detection.", range: 2..300, defaultValue: 10
} else {
input name: "motionSensitivity", type: "enum", title: "Presence Detection Sensitivity", description: "High - Suitable for bedrooms, small offices, studies, etc..
Medium - Sutiable for rooms like bathrooms, small conference rooms, etc..
Low - Suitable for complicated rooms with large area, which have plants and curtains.", options: ["1": "low", "2": "medium", "3": "high"], defaultValue: "2"
input name: "absenceDelayTimer", type: "number", title: "Absence Confirmation Period (10-300s)", description: "Used for accurate determination of 'no person' status, avoiding false alarms caused by personnel temporarily leaving or slight movements.", range: 10..300, defaultValue: 10
input name: "detectionRangeZones", type: "string", title: "Detection Range Zones", description: "Comma-separated ranges in 0.25 m steps, e.g. '0.5-2.0' or '0.25-1.5,3.0-5.0'. Leave blank for all zones (0-6 m)."
}
// Temperature & Humidity sampling
input name: "tempHumiditySamplingFrequency", type: "enum", title: "Temperature and Humidity Sampling Frequency", description: "Sampling time frequency, increasing lowers battery life.
Setting to 'Custom' allows specifying period, interval & threshold via additional preferences.", options: ["0": "Off", "1": "Low", "2": "Medium", "3": "High", "4": "Custom"], defaultValue: "1"
if (tempHumiditySamplingFrequency == "4") {
// Custom Temperature & Humidity Sampling on device
input name: "tempHumiditySamplingPeriod", type: "number", title: "Temperature and Humidity Sampling Period (s)", description: "How often in seconds temp & humidity readings are taken on the device when in custom mode.", range: 1..3600, defaultValue: 30
// Custom Temperature reporting
input name: "temperatureReportingMode", type: "enum", title: "Temperature Reporting Mode", description: "Temperature reporting type when in custom mode.", options: ["1": "Threshold only", "2": "Interval only", "3": "Threshold and Interval"], defaultValue: "1"
input name: "temperatureReportingInterval", type: "number", title: "Temperature Reporting Interval (s)", description: "Custom time interval for temperature data reporting.", range: 600..3600, defaultValue: 600
input name: "temperatureReportingThreshold", type: "decimal", title: "Temperature Reporting Threshold (°C)", description: "Reporting will trigger as temperature change reaches this value when in custom mode.", range: 0.2..3.0, defaultValue: 1.0
// Custom Humidity reporting
input name: "humidityReportingMode", type: "enum", title: "Humidity Reporting Mode", description: "Humidity reporting type when in custom mode.", options: ["1": "Threshold only", "2": "Interval only", "3": "Threshold and Interval"], defaultValue: "1"
input name: "humidityReportingInterval", type: "number", title: "Humidity Reporting Interval (s)", description: "Custom time interval for humidity data reporting.", range: 600..3600, defaultValue: 600
input name: "humidityReportingThreshold", type: "decimal", title: "Humidity Reporting Threshold (%)", description: "Reporting will trigger as humidity change reaches this value when in custom mode.", range: 2..10, defaultValue: 5.0
}
if (tempHumiditySamplingFrequency != "0") {
input name: "tempOffset", type: "decimal", title: "Temperature Offset (°)", description: "Change reflected on next temperature data update from the sensor.", range: "-100..100", defaultValue: 0.0
input name: "humidityOffset", type: "decimal", title: "Humidity Offset (%)", description: "Change reflected on next humidity data update from the sensor.", range: "-100..100", defaultValue: 0.0
}
// Illuminance sampling
input name: "lightSamplingFrequency", type: "enum", title: "Illuminance Sampling Frequency", description: "Sampling time frequency, increasing lowers battery life.
Setting to 'Custom' allows specifying period, interval & threshold via additional preferences.", options: ["0": "Off", "1": "Low", "2": "Medium", "3": "High", "4": "Custom"], defaultValue: "1"
// Custom Illuminance reporting
if (lightSamplingFrequency == "4") {
input name: "lightSamplingPeriod", type: "number", title: "Illuminance Sampling Period", description: "How often Illuminance readings are taken on the device when in custom mode. (s)", range: 0.5..3600, defaultValue: 30
input name: "lightReportingMode", type: "enum", title: "Illuminance Reporting Mode", description: "Illuminance reporting type when in custom mode.", options: ["1": "Threshold only", "2": "Interval only", "3": "Threshold and Interval"], defaultValue: "1"
input name: "lightReportingInterval", type: "number", title: "Illuminance Reporting Interval (s)", description: "Custom interval for Illuminance data reporting.", range: 20..3600, defaultValue: 600
input name: "lightReportingThreshold", type: "decimal", title: "Illuminance Reporting Threshold (%)", description: "Reporting will trigger as Illuminance percentage change reaches this value when in custom mode.", range: 3..20, defaultValue: 20
}
// LED night settings
input name: "ledDisabledNight", type: "bool", title: "LED Disabled at Night", description: "Enabling allows specifiying custom schedule by revelaling an additional preference.", defaultValue: false
if (ledDisabledNight) {
input name: "ledNightTimeSchedule", type: "string", title: "LED Night Time Schedule (HH:MM-HH:MM)", description: "e.g. '21:00-09:00'. Only active when LED Disabled at Night is enabled."
}
// Advanced/Experimental options
input (name: "advancedOptions", type: "bool", title: "Advanced/Experimental Options", description: "WARNING: Enabling these settings may result in losing the mmWave sensor's current calibration.
Show advanced/experimental configuration options (refresh page to see options)", defaultValue: false, submitOnChange: true)
if (advancedOptions == true) {
input name: "aiInterferenceIdentification", type: "bool", title: "AI Interference Identification", description: "WARNING: Changing this setting may result in losing the mmWave sensor's current calibration. You may want to re-run Spatial Learning if this setting is changed!
Designed to enhance detection accuracy by distinguishing between human presence and moving, non-human objects. It enables the sensor to learn the environment and ignore false triggers caused by common household items, thereby reducing ghosting and false alarms.", defaultValue: false
input name: "aiSensitivityAdaptive", type: "bool", title: "AI Adaptive Sensitivity", description: "WARNING: Changing this setting may result in losing the mmWave sensor's current calibration. You may want to re-run Spatial Learning if this setting is changed!
Uses machine learning to automatically adjust motion detection sensitivity based on the environment.", defaultValue: false
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// PARSE
// ════════════════════════════════════════════════════════════════════════════
void parse(String description) {
checkDriverVersion()
state.rxCounter = (state.rxCounter ?: 0) + 1
setHealthStatusOnline()
// Fast-path: Xiaomi struct in cluster 0000
if (description.contains("cluster: 0000") && description.contains("attrId: FF01")) {
parseAqaraAttributeFF01(description); return
}
if (description.contains("cluster: 0000") && description.contains("attrId: FF02")) {
parseAqaraAttributeFF02(description); return
}
Map descMap = [:]
try { descMap = zigbee.parseDescriptionAsMap(description) }
catch (e) { logWarn "parse exception: ${e}"; return }
logDebug "parse: ${descMap}"
if (descMap.attrId != null) {
List attrData = [[cluster: descMap.cluster, attrId: descMap.attrId,
value: descMap.value, status: descMap.status]]
descMap.additionalAttrs?.each {
attrData << [cluster: descMap.cluster, attrId: it.attrId, value: it.value, status: it.status]
}
attrData.each { parseAttribute(description, descMap, it) }
}
else if (descMap.profileId == "0000") {
parseZDOcommand(descMap)
}
else if (descMap.clusterId != null && descMap.profileId == "0104") {
parseZHAcommand(descMap)
}
else {
logDebug "Unprocessed: cluster=${descMap.clusterId} cmd=${descMap.command} data=${descMap.data}"
}
}
private void parseAttribute(String description, Map descMap, Map it) {
if (it.status == "86") { logWarn "Unsupported cluster ${it.cluster} attr ${it.attrId}"; return }
switch (it.cluster) {
case "0400": // Illuminance
if (it.attrId == "0000") illuminanceEvent(Integer.parseInt(it.value, 16))
break
case "0402": // Temperature
if (it.attrId == "0000") temperatureEvent(Integer.parseInt(it.value, 16) / 100.0)
break
case "0405": // Humidity
if (it.attrId == "0000") humidityEvent(Integer.parseInt(it.value, 16) / 100.0)
break
case "0001": // Battery
if (it.attrId == "0020" && it.value != "00") {
//logDebug "parseAttribute 0x0001 - battery percentage: ${Integer.parseInt(it.value, 16)}%"
voltageAndBatteryEvents(Integer.parseInt(it.value, 16) / 10.0)
} else {
logWarn "parseAttribute FP300 unknown report (cluster=0x${it.cluster} attrId=0x${it.attrId} value=0x${it.value})"
}
break
case "0000":
if (it.attrId == "0001") sendRttEvent()
else if (it.attrId == "0005") sendInfoEvent("Button was pressed – device awake for 15 min")
else if (it.attrId == "FF01") parseAqaraAttributeFF01(description)
break
case "FCC0":
parseAqaraClusterFCC0(description, descMap, it)
break
default:
logDebug "Unprocessed attr: cluster=${it.cluster} attrId=${it.attrId} value=${it.value}"
}
}
// ════════════════════════════════════════════════════════════════════════════
// AQARA FCC0 CLUSTER PARSING
// ════════════════════════════════════════════════════════════════════════════
private void parseAqaraClusterFCC0(String description, Map descMap, Map it) {
int value = safeToInt(it.value)
switch (it.attrId) {
case "0005":
//logDebug "Device button pressed"
logDebug "parseAqaraClusterFCC0 - Device button pressed"
break
case "0016" : // FP300 unknown report
logDebug "Received FP300 unknown report (cluster=0x${it.cluster} attrId=0x${it.attrId} value=0x${it.value})"
break
case "0017": // Battery voltage (mV)
sendVoltageEvent(Integer.parseInt(it.value, 16) / 1000.0)
logDebug "FP300 battery voltage: ${Integer.parseInt(it.value, 16) / 1000.0}V (${value} mV)"
break
case "0018": // Battery percentage
sendBatteryEvent(Integer.parseInt(it.value, 16))
logDebug "FP300 battery percentage: ${Integer.parseInt(it.value, 16)}%"
break
case "00E6" : // FP300 unknown report
logDebug "Received FP300 unknown report (cluster=0x${it.cluster} attrId=0x${it.attrId} value=0x${it.value})"
break
case "00E8":
logWarn "FP300 restart response (cluster=FCC0 attrId=00E8 value=${it.value})"
break
case "00EE" : // FP300 unknown report
logDebug "Received FP300 unknown report (cluster=0x${it.cluster} attrId=0x${it.attrId} value=0x${it.value})"
break
case "00F7":
decodeAqaraStruct(description)
break
case "00FC":
log.warn "LUMI LEAVE report received (cluster=FCC0 attrId=00FC value=${it.value})"
break
case "010C": // Motion sensitivity
device.updateSetting("motionSensitivity", [value: value.toString(), type: "enum"])
logDebug "Motion sensitivity: ${value}"
break
case "0142": // mmWave detection state (i.e. Room state / presence)
state.mmwaveState = value
updateMotionState("mmwave")
roomStateEvent(value)
logDebug "(attr. 0x0142) roomState (mmWave 'presence') is ${value ? 'occupied' : 'unoccupied'} (${value})"
break
case "014D": // PIR detection state
state.pirState = value
updateMotionState("pir")
pirDetectionEvent(value)
logDebug "(attr. 0x014D) pirDetection is ${value ? 'active' : 'inactive'} (${value})"
break
case "014F": // PIR detection interval
value = Integer.parseInt(it.value, 16)
device.updateSetting("pirDetectionInterval", [value: value.toString(), type: "number"])
logDebug "PIR detection interval: ${value} seconds"
break
case "015D": // AI adaptive sensitivity
state.aiSensitivityAdaptiveCache = value ? true : false
device.updateSetting("aiSensitivityAdaptive", [value: value ? true : false, type: "bool"])
logDebug "AI adaptive sensitivity: ${value ? 'on' : 'off'}"
break
case "015E": // AI interference identification
state.aiInterferenceIdentificationCache = value ? true : false
device.updateSetting("aiInterferenceIdentification", [value: value ? true : false, type: "bool"])
logDebug "AI interference identification: ${value ? 'on' : 'off'}"
break
case "015F": // Target distance (cm)
value = Integer.parseInt(it.value, 16)
targetDistanceEvent(value)
logDebug "(0x015F) received FP300 target_distance report: ${value} (cluster=0x${it.cluster} attrId=0x${it.attrId} value=0x${it.value})"
break
case "0162": // Temp/humidity sampling period (ms)
value = Integer.parseInt(it.value, 16) / 1000
device.updateSetting("tempHumiditySamplingPeriod", [value: value.toString(), type: "number"])
logDebug "FP300 temp/humidity sampling period: ${value} seconds"
break
case "0163": // Temperature reporting interval (ms)
value = Integer.parseInt(it.value, 16) / 1000
device.updateSetting("temperatureReportingInterval", [value: value.toString(), type: "number"])
logDebug "FP300 temperature reporting interval: ${value} seconds"
break
case "0164": // Temperature reporting threshold (degrees Celsius)
float f_value = Integer.parseInt(it.value, 16) / 100.0
device.updateSetting("temperatureReportingThreshold", [value: f_value.toString(), type: "decimal"])
logDebug "FP300 temperature reporting threshold: ${f_value}°C"
break
case "0165": // Temperature reporting mode
device.updateSetting("temperatureReportingMode", [value: value.toString(), type: "enum"])
def modes = ["unknown", "threshold", "reporting interval", "threshold and interval"]
logDebug "FP300 temperature reporting mode: ${modes[value] ?: 'unknown'} (${value})"
break
case "016A": // Humidity reporting interval (ms)
value = Integer.parseInt(it.value, 16) / 1000
device.updateSetting("humidityReportingInterval", [value: value.toString(), type: "number"])
logDebug "FP300 humidity reporting interval: ${value} seconds"
break
case "016B": // Humidity reporting threshold (%*100)
float f_value = Integer.parseInt(it.value, 16) / 100.0
device.updateSetting("humidityReportingThreshold", [value: f_value.toString(), type: "decimal"])
logDebug "FP300 humidity reporting threshold: ${f_value}%"
break
case "016C": // Humidity reporting mode
device.updateSetting("humidityReportingMode", [value: value.toString(), type: "enum"])
def modes = ["unknown", "threshold", "reporting interval", "threshold and interval"]
logDebug "FP300 humidity reporting mode: ${modes[value] ?: 'unknown'} (${value})"
break
case "0170": // Temp/humidity sampling frequency
device.updateSetting("tempHumiditySamplingFrequency", [value: value.toString(), type: "enum"])
def frequencies = ["off", "low", "medium", "high", "custom"]
logDebug "FP300 temp/humidity sampling frequency: ${frequencies[Integer.parseInt(it.value, 16)] ?: 'unknown'} (${value})"
break
case "0192": // Light sampling frequency
device.updateSetting("lightSamplingFrequency", [value: value.toString(), type: "enum"])
def frequencies = ["off", "low", "medium", "high", "custom"]
logDebug "FP300 light sampling frequency: ${frequencies[Integer.parseInt(it.value, 16)] ?: 'unknown'} (${value})"
break
case "0193": // Light sampling period (ms)
value = Integer.parseInt(it.value, 16) / 1000
device.updateSetting("lightSamplingPeriod", [value: value.toString(), type: "number"])
logDebug "FP300 light sampling period: ${value} seconds"
break
case "0194": // Light reporting interval (ms)
value = Integer.parseInt(it.value, 16) / 1000
device.updateSetting("lightReportingInterval", [value: value.toString(), type: "number"])
logDebug "FP300 light reporting interval: ${value} seconds"
break
case "0195": // Light reporting threshold (%*100)
float f_value = Integer.parseInt(it.value, 16) / 100.0
device.updateSetting("lightReportingThreshold", [value: f_value.toString(), type: "decimal"])
logDebug "FP300 light reporting threshold: ${f_value}%"
break
case "0196": // Light reporting mode
device.updateSetting("lightReportingMode", [value: value.toString(), type: "enum"])
def modes = ["No reporting", "Threshold only", "Interval only", "Threshold and Interval"]
logDebug "FP300 light reporting mode: ${modes[Integer.parseInt(it.value, 16)] ?: 'unknown'} (${value})"
break
case "0197": // Absence delay timer
value = Integer.parseInt(it.value, 16)
device.updateSetting("absenceDelayTimer", [value: value.toString(), type: "number"])
logDebug "FP300 absence delay timer: ${value} seconds"
break
case "0198": // Track target distance status
value = Integer.parseInt(it.value, 16)
if (value == 1) sendInfoEvent("Target distance tracking enabled for ~3 minutes")
if (value == 0 && device.currentValue("targetDistance") != null) {
device.deleteCurrentState("targetDistance")
logInfo "Target distance tracking disabled – attribute removed"
}
break
case "0199": // Presence Detection Mode
def modes = ["both", "mmwave", "pir"]
def modeName = modes[value] ?: "both"
device.updateSetting("presenceDetectionMode", [value: modeName, type: "enum"])
state.presenceDetectionModeCache = modeName
logDebug "FP300 presence detection mode: ${modes[value] ?: 'both'}"
break
case "019A": // Detection range zones (bitmap)
parseDetectionRangeZonesReport(it.value)
logDebug "FP300 DetectionRangeZones value = ${value})"
break
case "0203": // LED disabled at night
def ledState = value ? "on" : "off"
device.updateSetting("ledDisabledNight", [value: value ? true : false, type: "bool"])
logDebug "FP300 LED disabled at night: ${ledState} (value=${value})"
break
case "023E": // LED night time schedule (UINT32)
long sched = Long.parseLong(it.value, 16)
def schedStr = String.format("%02d:%02d-%02d:%02d",
sched & 0xFF, (sched >> 8) & 0xFF, (sched >> 16) & 0xFF, (sched >> 24) & 0xFF)
device.updateSetting("ledNightTimeSchedule", [value: schedStr, type: "string"])
logDebug "FP300 LED night time schedule: ${schedStr} (raw: 0x${it.value})"
break
default:
logDebug "Unprocessed FCC0 attr: attrId=${it.attrId} value=${it.value}"
}
}
private void parseDetectionRangeZonesReport(String rawHex) {
if (!rawHex || rawHex.isEmpty() || rawHex == "00") {
return
}
def bitmapHex = rawHex
if (rawHex.startsWith("0300")) bitmapHex = rawHex.substring(4)
else if (rawHex.startsWith("00")) bitmapHex = rawHex.substring(2)
def rangeValue = 0
if (bitmapHex.length() >= 6) {
rangeValue = Integer.parseInt(bitmapHex[0..1], 16) | (Integer.parseInt(bitmapHex[2..3], 16) << 8) | (Integer.parseInt(bitmapHex[4..5], 16) << 16)
} else if (bitmapHex.length() >= 4) {
rangeValue = Integer.parseInt(bitmapHex[0..1], 16) | (Integer.parseInt(bitmapHex[2..3], 16) << 8)
} else if (bitmapHex.length() >= 2) {
rangeValue = Integer.parseInt(bitmapHex[0..1], 16)
}
}
// ════════════════════════════════════════════════════════════════════════════
// XIAOMI STRUCT (attrId FF01 / FF02)
// ════════════════════════════════════════════════════════════════════════════
void parseAqaraAttributeFF01(String description) {
def valueHex = description.split(",").find { it.split(":")[0].trim() == "value" }?.split(":")[1]?.trim()
parseBatteryTLV(valueHex)
}
void parseAqaraAttributeFF02(String description) {
def valueHex = description.split(",").find { it.split(":")[0].trim() == "value" }?.split(":")[1]?.trim()
parseBatteryTLV(valueHex)
}
private void parseBatteryTLV(String valueHex) {
if (!valueHex) return
if (!(valueHex ==~ /(?i)^[0-9a-f]+$/)) {
StringBuilder sb = new StringBuilder()
for (char ch : valueHex.toCharArray()) sb.append(String.format("%02X", ((int) ch) & 0xFF))
valueHex = sb.toString()
}
int L = valueHex.length()
for (int i = 0; i <= L - 8; i += 2) {
if (valueHex.substring(i, i+2).equalsIgnoreCase("01") && valueHex.substring(i+2, i+4).equalsIgnoreCase("21")) {
int rawmV = Integer.parseInt(valueHex.substring(i+6, i+8) + valueHex.substring(i+4, i+6), 16)
if (rawmV > 0) voltageAndBatteryEvents(rawmV / 1000.0G)
return
}
}
}
void decodeAqaraStruct(String description) {
def valueHex = description.split(",").find { it.split(":")[0].trim() == "value" }?.split(":")[1]?.trim()
if (!valueHex) return
int MsgLength = valueHex.size()
for (int i = 2; i < (MsgLength - 3); ) {
int dataType = Integer.parseInt(valueHex[(i+2)..(i+3)], 16)
int tag = Integer.parseInt(valueHex[(i+0)..(i+1)], 16)
int rawValue = 0
switch (dataType) {
case 0x08: case 0x10: case 0x18: case 0x20: case 0x28: case 0x30:
rawValue = Integer.parseInt(valueHex[(i+4)..(i+5)], 16)
switch (tag) {
case 0x18: sendBatteryEvent(rawValue); break
case 0x64: logDebug "FP300 presence tag 0x64: ${rawValue}"; break
default: logDebug "decodeAqaraStruct 1B unhandled tag=0x${valueHex[(i+0)..(i+1)]} type=0x${valueHex[(i+2)..(i+3)]} val=${rawValue}"
}
i += 6; break
case 0x21:
rawValue = Integer.parseInt(valueHex[(i+6)..(i+7)] + valueHex[(i+4)..(i+5)], 16)
switch (tag) {
case 0x01: voltageAndBatteryEvents(rawValue / 1000.0); break
case 0x17: sendVoltageEvent(rawValue / 1000.0); break
default: logDebug "decodeAqaraStruct 2B unhandled tag=0x${valueHex[(i+0)..(i+1)]} val=${rawValue}"
}
i += 8; break
case 0x0B: case 0x1B: case 0x23: case 0x2B:
i += 12; break
case 0x24:
i += 14; break
default:
logDebug "decodeAqaraStruct unknown dataType=0x${valueHex[(i+2)..(i+3)]} at i=${i}"
// WARNING: unknown data type size; incrementing by 2 may mis-parse remaining struct data.
i += 2; break
}
}
}
// ════════════════════════════════════════════════════════════════════════════
// EVENT HELPERS
// ════════════════════════════════════════════════════════════════════════════
void updateMotionState(String source = null) {
boolean mmwave = state.mmwaveState == 1 ? true : false
boolean pir = state.pirState == 1 ? true : false
String newState
String mode = settings?.presenceDetectionMode ?: "both"
switch (mode) {
case "both":
newState = mmwave || pir ? "active" : "inactive"
break
case "pir":
newState = pir ? "active" : "inactive"
break
case "mmwave":
newState = mmwave ? "active" : "inactive"
break
default:
log.warn "Unknown Presence Detection Mode = ${mode}"
}
if (device.currentValue("motion") != newState) {
sendEvent(name: "motion", value: newState, descriptionText: "Motion ${newState} (source: ${source})")
logInfo "motion has changed to ${newState} (source: ${source})"
}
logDebug "updateMotionState() - source = ${source}, mmwave = ${mmwave}, pir = ${pir}, mode = ${mode}, newState = ${newState}, current Motion = ${device.currentValue("motion")}"
}
def roomStateEvent(int value) {
String status = value ? "occupied" : "unoccupied"
sendEvent(name: "roomState", "value": status)
logInfo "roomState (mmWave 'presence') is ${status}"
}
def pirDetectionEvent(int value) {
String status = value ? "active" : "inactive"
sendEvent(name: "pirDetection", "value": status)
logInfo "pirDetection is ${status}"
}
void illuminanceEvent(int rawLux) {
if (rawLux == 0xFFFF) { logWarn "Ignored rawLux 0xFFFF"; return }
int lux = rawLux > 0 ? (int) Math.round(Math.pow(10, ((rawLux - 1) / 10000.0))) : 0
if (lux > 0xFFDC) lux = 0xFFDC
def corrected = Math.round(lux)
sendEvent(name: "illuminance", value: corrected, unit: "lx")
logInfo "illuminance is ${corrected} lx"
}
void temperatureEvent(double temperature) {
double offset = (settings?.tempOffset ?: 0) as double
double temp = temperature + offset
if (location.temperatureScale == "F") { temp = temp * 1.8 + 32 }
def rounded = new BigDecimal(temp).setScale(1, BigDecimal.ROUND_HALF_UP)
sendEvent(name: "temperature", value: rounded, unit: "°${location.temperatureScale}")
logInfo "temperature is ${rounded}°${location.temperatureScale}"
}
void humidityEvent(double humidity) {
double offset = (settings?.humidityOffset ?: 0) as double
int hum = (int) Math.max(0, Math.min(100, Math.round(humidity + offset)))
sendEvent(name: "humidity", value: hum, unit: "%")
logInfo "humidity is ${hum}%"
}
void targetDistanceEvent(int distanceCm) {
def d = new BigDecimal(distanceCm / 100.0).setScale(2, RoundingMode.HALF_UP)
sendEvent(name: "targetDistance", value: d)
logInfo "target distance is ${d} m"
}
void voltageAndBatteryEvents(def rawVolts) {
def minV = 2.85; def maxV = 3.0
def pct = Math.min(100, Math.max(0, Math.round(((rawVolts - minV) / (maxV - minV)) * 100)))
sendEvent(name: "voltage", value: rawVolts, unit: "V")
sendEvent(name: "battery", value: pct, unit: "%")
logInfo "battery is ${pct}% (${rawVolts}V)"
}
void sendVoltageEvent(def rawVolts) {
sendEvent(name: "voltage", value: rawVolts, unit: "V")
logInfo "battery voltage is ${rawVolts}V"
}
void sendBatteryEvent(int pct) {
sendEvent(name: "battery", value: pct, unit: "%")
logInfo "battery is ${pct}%"
}
// ════════════════════════════════════════════════════════════════════════════
// ZDO / ZHA COMMAND PARSING
// ════════════════════════════════════════════════════════════════════════════
void parseZDOcommand(Map descMap) {
switch (descMap.clusterId) {
case "0013":
logInfo "Device announcement received"
fp300BlackMagic()
break
case "8021":
logDebug "Bind response: ${descMap.data[1] == '00' ? 'Success' : 'Failure'}"
break
default:
logDebug "ZDO: clusterId=${descMap.clusterId} data=${descMap.data}"
}
}
void parseZHAcommand(Map descMap) {
switch (descMap.command) {
case "04":
logDebug "Write Attribute Response: ${descMap.data[0] == '00' ? 'Success' : 'Failure'}"
break
case "07":
logDebug "Configure Reporting Response for cluster ${descMap.clusterId}: ${descMap.data[0] == '00' ? 'Success' : 'Failure'}"
break
case "0B":
if (descMap.data[1] != "00") logDebug "ZCL Default Response cmd=${descMap.data[0]} status=${descMap.data[1]} cluster=${descMap.clusterId}"
break
default:
logDebug "ZHA: clusterId=${descMap.clusterId} cmd=${descMap.command} data=${descMap.data}"
}
}
// ════════════════════════════════════════════════════════════════════════════
// COMMANDS
// ════════════════════════════════════════════════════════════════════════════
void ping() {
logInfo "ping..."
scheduleCommandTimeoutCheck()
state.pingTime = new Date().getTime()
sendZigbeeCommands(zigbee.readAttribute(zigbee.BASIC_CLUSTER, 0x01, [:], 0))
}
void sendRttEvent() {
def rtt = (new Date().getTime()).toInteger() - (state.pingTime?.toInteger() ?: 0)
logInfo "Round-Trip Time: ${rtt} ms"
state.rtt = rtt
sendInfoEvent("Round-Trip Time: ${rtt} ms")
}
void restartDevice() {
logInfo "Restarting device..."
sendZigbeeCommands(zigbee.writeAttribute(0xFCC0, 0x00E8, DataType.BOOLEAN, 0x00, [mfgCode: 0x115F], 0))
}
void startSpatialLearning() {
logInfo "Starting Spatial Learning..."
sendZigbeeCommands(zigbee.writeAttribute(0xFCC0, 0x0157, DataType.UINT8, 0x01, [mfgCode: 0x115F], 100)) //0x0157 is believed to start spacial learning on the FP300 sensor
sendInfoEvent("Spatial Learning started – ensure room is empty for 30 seconds")
runIn(35, "spatialLearningReset", [overwrite: true])
}
void spatialLearningReset() {
sendInfoEvent("Spatial Learning complete")
logInfo "Spatial Learning reset to idle"
}
void trackTargetDistance() {
logInfo "Enabling target distance tracking (~3 min)..."
sendInfoEvent("Requesting target distance tracking")
sendZigbeeCommands(zigbee.writeAttribute(0xFCC0, 0x0198, DataType.UINT8, 0x01, [mfgCode: 0x115F], 0))
}
void refresh() {
logInfo "Refreshing FP300 parameters..."
List cmds = []
cmds += zigbee.readAttribute(0xFCC0, [0x010C, 0x0142, 0x014D, 0x014F, 0x0197, 0x0199, 0x015D, 0x015E], [mfgCode: 0x115F], delay=200)
cmds += zigbee.readAttribute(0xFCC0, [0x0162, 0x0170, 0x0192, 0x0193], [mfgCode: 0x115F], delay=200)
cmds += zigbee.readAttribute(0xFCC0, [0x0163, 0x0164, 0x0165], [mfgCode: 0x115F], delay=200)
cmds += zigbee.readAttribute(0xFCC0, [0x016A, 0x016B, 0x016C], [mfgCode: 0x115F], delay=200)
cmds += zigbee.readAttribute(0xFCC0, [0x0194, 0x0195, 0x0196], [mfgCode: 0x115F], delay=200)
cmds += zigbee.readAttribute(0xFCC0, 0x019A, [mfgCode: 0x115F], delay=200)
cmds += zigbee.readAttribute(0xFCC0, [0x0203, 0x023E], [mfgCode: 0x115F], delay=200)
cmds += zigbee.readAttribute(0x0402, 0x0000, [:], delay=200)
cmds += zigbee.readAttribute(0x0405, 0x0000, [:], delay=200)
cmds += zigbee.readAttribute(0x0400, 0x0000, [:], delay=200)
sendZigbeeCommands(cmds)
}
// ════════════════════════════════════════════════════════════════════════════
// UPDATED (preferences saved)
// ════════════════════════════════════════════════════════════════════════════
void updated() {
log.info "${device.displayName} updated() called."
checkDriverVersion()
if (state.params != null)state.remove("params") //remove legacy state.params variable
if (logEnable) runIn(1800, "logsOff", [overwrite: true, misfire: "ignore"]) //Enable the debug logging for 30 minutes (i.e. 1800 seconds)
else unschedule("logsOff")
runIn(DEFAULT_POLLING_INTERVAL, "deviceHealthCheck", [overwrite: true, misfire: "ignore"])
List cmds = []
int val = 0
// Only update Presence Detection Mode on the FP300 sensor if the value has changed. Otherwise, the mmWave sensor calibration will get messed up!
logDebug "updated() - presenceDetectionModeCache = ${state.presenceDetectionModeCache}, settings.presenceDetectionMode = ${settings.presenceDetectionMode}"
if (state.presenceDetectionModeCache != settings.presenceDetectionMode) {
val = ["both": 0, "mmwave": 1, "pir": 2][settings.presenceDetectionMode] ?: 0
cmds += zigbee.writeAttribute(0xFCC0, 0x0199, 0x20, val, [mfgCode: 0x115F], delay=1000)
//state.presenceDetectionModeCache = settings.presenceDetectionMode // Will be stored after reply is parsed
log.warn "updated() - presenceDetectionMode setting changed, ${val} sent to FP300 sensor. Please run Spatial Learning with the room empty!"
} else {
logDebug "updated() - presenceDetectionMode setting unchanged, not sent to FP300 sensor"
}
if (presenceDetectionMode == "pir") {
val = safeToInt(settings.pirDetectionInterval)
cmds += zigbee.writeAttribute(0xFCC0, 0x014F, 0x21, val, [mfgCode: 0x115F], delay=1000)
} else {
val = safeToInt(settings.motionSensitivity)
cmds += zigbee.writeAttribute(0xFCC0, 0x010C, 0x20, val, [mfgCode: 0x115F], delay=1000)
val = safeToInt(settings.absenceDelayTimer)
cmds += zigbee.writeAttribute(0xFCC0, 0x0197, 0x23, val, [mfgCode: 0x115F], delay=1000)
def parseResult = parseDetectionRangeInput(settings?.detectionRangeZones ?: "")
def newBitmapHex = parseResult.success ? String.format("%06X", parseResult.bitmap) : null
if (newBitmapHex) {
parseResult.errors.each { logWarn "Detection range: ${it}" }
cmds += zigbee.writeAttribute(0xFCC0, 0x019A, 0x41, detectionRangeBitmapToPayload(parseResult.bitmap), [mfgCode: 0x115F], delay=1000)
}
}
// Adcanced/Experimental AI Features
if (advancedOptions == true) {
// Only update aiInterferenceIdentification on the FP300 sensor if the value has changed. Otherwise, the mmWave sensor calibration will get messed up!
logDebug "updated() - aiInterferenceIdentificationCache = ${state.aiInterferenceIdentificationCache}, settings.presenceDetectionMode = ${settings.aiInterferenceIdentification}"
if (state.aiInterferenceIdentificationCache != settings.aiInterferenceIdentification) {
cmds += zigbee.writeAttribute(0xFCC0, 0x015E, 0x20, settings.aiInterferenceIdentification ? 1 : 0, [mfgCode: 0x115F], delay=1000)
//state.aiInterferenceIdentificationCache = settings.aiInterferenceIdentification // Will be stored after reply is parsed
log.warn "updated() - aiInterferenceIdentification setting changed, ${settings.aiInterferenceIdentification ? 1 : 0} sent to FP300 sensor. May need to run Spatial Learning with the room empty!"
} else {
logDebug "updated() - aiInterferenceIdentification setting unchanged, not sent to FP300 sensor"
}
// Only update aiSensitivityAdaptive on the FP300 sensor if the value has changed. Otherwise, the mmWave sensor calibration will get messed up!
logDebug "updated() - aiSensitivityAdaptiveCache = ${state.aiSensitivityAdaptiveCache}, settings.presenceDetectionMode = ${settings.aiSensitivityAdaptive}"
if (state.aiSensitivityAdaptiveCache != settings.aiSensitivityAdaptive) {
cmds += zigbee.writeAttribute(0xFCC0, 0x015D, 0x20, settings.aiSensitivityAdaptive ? 1 : 0, [mfgCode: 0x115F], delay=1000)
//state.aiSensitivityAdaptiveCache = settings.aiSensitivityAdaptive // Will be stored after reply is parsed
log.warn "updated() - aiSensitivityAdaptive setting changed, ${settings.aiSensitivityAdaptive ? 1 : 0} sent to FP300 sensor. May need to run Spatial Learning with the room empty!"
} else {
logDebug "updated() - aiSensitivityAdaptive setting unchanged, not sent to FP300 sensor"
}
}
// Temperature and Humidity
if (tempHumiditySamplingFrequency != "4") { // If Low, Med, or High
val = 3 // set Temperature and Humiity Sampling modes to "Threshold and Interval" to ensure data is transmitted from the sensor
cmds += zigbee.writeAttribute(0xFCC0, 0x0165, 0x20, val, [mfgCode: 0x115F], delay=1000)
cmds += zigbee.writeAttribute(0xFCC0, 0x016C, 0x20, val, [mfgCode: 0x115F], delay=1000)
}
else {
// Custom Temperature and Humidity sampling & reporting
val = safeToInt(settings.tempHumiditySamplingPeriod)
cmds += zigbee.writeAttribute(0xFCC0, 0x0162, 0x23, val * 1000, [mfgCode: 0x115F], delay=1000)
cmds += zigbee.writeAttribute(0xFCC0, 0x0164, 0x21, ((settings.temperatureReportingThreshold as BigDecimal) * 100) as Integer, [mfgCode: 0x115F], delay=1000)
val = safeToInt(settings.temperatureReportingInterval)
cmds += zigbee.writeAttribute(0xFCC0, 0x0163, 0x23, val * 1000, [mfgCode: 0x115F], delay=1000)
val = safeToInt(settings.temperatureReportingMode)
cmds += zigbee.writeAttribute(0xFCC0, 0x0165, 0x20, val, [mfgCode: 0x115F], delay=1000)
cmds += zigbee.writeAttribute(0xFCC0, 0x016B, 0x21, ((settings.humidityReportingThreshold as BigDecimal) * 100) as Integer, [mfgCode: 0x115F], delay=1000)
val = safeToInt(settings.humidityReportingInterval)
cmds += zigbee.writeAttribute(0xFCC0, 0x016A, 0x23, val * 1000, [mfgCode: 0x115F], delay=1000)
val = safeToInt(settings.humidityReportingMode)
cmds += zigbee.writeAttribute(0xFCC0, 0x016C, 0x20, val, [mfgCode: 0x115F], delay=1000)
}
val = safeToInt(settings.tempHumiditySamplingFrequency)
cmds += zigbee.writeAttribute(0xFCC0, 0x0170, 0x20, val, [mfgCode: 0x115F], delay=1000)
// Illuminance
if (lightSamplingFrequency != "4") { // If Low, Med, or High
val = 3 // set LightSamplingFrequency to "Threshold and Interval" to ensure data is transmitted from the sensor
cmds += zigbee.writeAttribute(0xFCC0, 0x0196, 0x20, val, [mfgCode: 0x115F], delay=1000)
}
else {
// Custom Illuminance sampling & reporting
val = safeToInt(settings.lightSamplingPeriod)
cmds += zigbee.writeAttribute(0xFCC0, 0x0193, 0x23, val * 1000, [mfgCode: 0x115F], delay=1000)
val = safeToInt(settings.lightReportingInterval)
cmds += zigbee.writeAttribute(0xFCC0, 0x0194, 0x23, val * 1000, [mfgCode: 0x115F], delay=1000)
cmds += zigbee.writeAttribute(0xFCC0, 0x0195, 0x21, ((settings.lightReportingThreshold as BigDecimal) * 100) as Integer, [mfgCode: 0x115F], delay=1000)
val = safeToInt(settings.lightReportingMode)
cmds += zigbee.writeAttribute(0xFCC0, 0x0196, 0x20, val, [mfgCode: 0x115F], delay=1000)
}
val = safeToInt(settings.lightSamplingFrequency)
cmds += zigbee.writeAttribute(0xFCC0, 0x0192, 0x20, val, [mfgCode: 0x115F], delay=1000)
// LED Disable at Night
cmds += zigbee.writeAttribute(0xFCC0, 0x0203, 0x10, settings.ledDisabledNight ? 1 : 0, [mfgCode: 0x115F], delay=1000)
if (ledDisabledNight && ledNightTimeSchedule != null) {
def schedStr = settings?.ledNightTimeSchedule ?: "21:00-09:00"
def payload = ledNightTimeToPayload(schedStr)
if (payload != null) cmds += zigbee.writeAttribute(0xFCC0, 0x023E, 0x23, payload.intValue(), [mfgCode: 0x115F], delay=1000)
}
sendZigbeeCommands(cmds)
runIn(30, "refresh")
}
// ════════════════════════════════════════════════════════════════════════════
// LIFECYCLE
// ════════════════════════════════════════════════════════════════════════════
void installed() {
log.info "${device.displayName} installed() called. FP300 driver v${version()}"
sendHealthStatusEvent("unknown")
initializeVars(true)
initialize()
}
void configure() {
log.info "${device.displayName} configure() called"
unschedule()
if (logEnable) runIn(1800, "logsOff", [overwrite: true, misfire: "ignore"]) //Enable the debug logging for 30 minutes (i.e. 1800 seconds)
initializeVars(false)
runIn(DEFAULT_POLLING_INTERVAL, "deviceHealthCheck", [overwrite: true, misfire: "ignore"])
runIn(5, "fp300BlackMagic")
runIn(15, "updated")
logWarn "configure() - If no further logs appear, make sure you have woken the FP300 by pressing the button on it."
}
void initialize() {
log.info "${device.displayName} initialize() called"
state.pirState = device.currentValue("pirDetection") == "active" ? 1 : 0
state.mmwaveState = device.currentValue("roomState") == "occupied" ? 1 : 0
}
void initializeVars(boolean fullInit = false) {
if (fullInit) state.clear()
if (state.params != null)state.remove("params") //remove legacy state.params variable
if (state.rxCounter == null) state.rxCounter = 0
if (state.txCounter == null) state.txCounter = 0
if (state.notPresentCounter == null) state.notPresentCounter = 0
if (settings?.logEnable == null) device.updateSetting("logEnable", true)
if (settings?.txtEnable == null) device.updateSetting("txtEnable", true)
if (settings?.presenceDetectionMode == null) device.updateSetting("presenceDetectionMode", [value: "both", type: "enum"])
if (settings?.absenceDelayTimer == null) device.updateSetting("absenceDelayTimer", [value: 10, type: "number"])
if (settings?.pirDetectionInterval == null) device.updateSetting("pirDetectionInterval", [value: 10, type: "number"])
if (settings?.aiInterferenceIdentification == null) device.updateSetting("aiInterferenceIdentification", false)
if (settings?.aiSensitivityAdaptive == null) device.updateSetting("aiSensitivityAdaptive", false)
if (settings?.tempOffset == null) device.updateSetting("tempOffset", [value: 0.0, type: "decimal"])
if (settings?.humidityOffset == null) device.updateSetting("humidityOffset", [value: 0.0, type: "decimal"])
if (settings?.motionSensitivity == null) device.updateSetting("motionSensitivity", [value: "2", type: "enum"])
if (settings?.tempHumiditySamplingFrequency == null) device.updateSetting("tempHumiditySamplingFrequency", [value: "1", type: "enum"])
if (settings?.lightSamplingFrequency == null) device.updateSetting("lightSamplingFrequency", [value: "1", type: "enum"])
if (settings?.ledDisabledNight == null) device.updateSetting("ledDisabledNight", false)
if (settings?.tempHumiditySamplingPeriod == null) device.updateSetting("tempHumiditySamplingPeriod", [value: 600, type: "number"])
if (settings?.lightSamplingPeriod == null) device.updateSetting("lightSamplingPeriod", [value: 30, type: "number"])
if (settings?.temperatureReportingInterval == null) device.updateSetting("temperatureReportingInterval", [value: 600, type: "number"])
if (settings?.temperatureReportingThreshold == null) device.updateSetting("temperatureReportingThreshold", [value: 1.0, type: "decimal"])
if (settings?.temperatureReportingMode == null) device.updateSetting("temperatureReportingMode", [value: "1", type: "enum"])
if (settings?.humidityReportingInterval == null) device.updateSetting("humidityReportingInterval", [value: 600, type: "number"])
if (settings?.humidityReportingThreshold == null) device.updateSetting("humidityReportingThreshold", [value: 5.0, type: "decimal"])
if (settings?.humidityReportingMode == null) device.updateSetting("humidityReportingMode", [value: "1", type: "enum"])
if (settings?.lightReportingInterval == null) device.updateSetting("lightReportingInterval", [value: 600, type: "number"])
if (settings?.lightReportingThreshold == null) device.updateSetting("lightReportingThreshold", [value: 20.0, type: "decimal"])
if (settings?.lightReportingMode == null) device.updateSetting("lightReportingMode", [value: "1", type: "enum"])
// if (settings?.ledNightTimeSchedule == null) device.updateSetting("ledNightTimeSchedule", [value: "21:00-09:00", type: "string"])
state.driverVersion = driverVersionAndTimeStamp()
}
// ════════════════════════════════════════════════════════════════════════════
// HEALTH CHECK
// ════════════════════════════════════════════════════════════════════════════
void setHealthStatusOnline() {
if ((state.rxCounter ?: 0) <= 2) return
sendHealthStatusEvent("online")
state.notPresentCounter = 0
unschedule("deviceCommandTimeout")
}
void deviceHealthCheck() {
state.notPresentCounter = (state.notPresentCounter ?: 0) + 1
if (state.notPresentCounter >= PRESENCE_COUNT_THRESHOLD) {
sendHealthStatusEvent("offline")
}
runIn(DEFAULT_POLLING_INTERVAL, "deviceHealthCheck", [overwrite: true, misfire: "ignore"])
}
private void scheduleCommandTimeoutCheck(int delay = COMMAND_TIMEOUT) {
runIn(delay, "deviceCommandTimeout")
}
void deviceCommandTimeout() {
logWarn "No ping response received"
// FP300 is battery-powered – don't aggressively flip offline on a missed ping
}
void sendHealthStatusEvent(String value) {
if (device.currentValue("healthStatus") != value) {
def msg = "healthStatus changed to ${value}"
sendEvent(name: "healthStatus", value: value, descriptionText: "${device.displayName} ${msg}")
value != "online" ? log.warn("${device.displayName} ${msg}") : log.info("${device.displayName} ${msg}")
}
}
// ════════════════════════════════════════════════════════════════════════════
// ZIGBEE INITIALIZATION (Black Magic for FP300)
// ════════════════════════════════════════════════════════════════════════════
void fp300BlackMagic() {
List cmds = []
// Bind temperature cluster (0x0402)
cmds += ["zdo bind ${device.deviceNetworkId} 0x01 0x01 0x0402 {${device.zigbeeId}} {}", "delay 50"]
// Bind humidity cluster (0x0405)
cmds += ["zdo bind ${device.deviceNetworkId} 0x01 0x01 0x0405 {${device.zigbeeId}} {}", "delay 50"]
// Bind illuminance cluster (0x0400)
cmds += ["zdo bind ${device.deviceNetworkId} 0x01 0x01 0x0400 {${device.zigbeeId}} {}", "delay 50"]
// Bind manufacturer cluster and read initial values
cmds += ["zdo bind ${device.deviceNetworkId} 0x01 0x01 0xFCC0 {${device.zigbeeId}} {}"]
// Call routine to send the Zigbee commands
sendZigbeeCommands(cmds)
}
// ════════════════════════════════════════════════════════════════════════════
// DETECTION RANGE HELPERS
// ════════════════════════════════════════════════════════════════════════════
Map parseDetectionRangeInput(String input) {
def result = [success: true, bitmap: 0, zones: [], errors: []]
if (!input || input.trim().isEmpty()) { result.bitmap = 0xFFFFFF; return result }
input.split(",").each { range ->
def parts = range.trim().split("-")
if (parts.size() != 2) { result.errors << "Invalid format '${range}'"; return }
try {
def startM = parts[0].trim() as BigDecimal
def endM = parts[1].trim() as BigDecimal
startM = Math.max(0G, Math.min(6.0G, startM)) // clip first
endM = Math.max(0G, Math.min(6.0G, endM))
if (startM >= endM) { result.errors << "Start >= end or out of range in '${range}'"; return }
int startZ = (int)(startM / 0.25)
int endZ = (int) Math.ceil(endM / 0.25) - 1
for (int i = Math.max(0, startZ); i <= Math.min(23, endZ); i++) result.bitmap |= (1 << i)
result.zones << "${startM}m-${endM}m"
} catch (NumberFormatException e) { result.errors << "Invalid number in '${range}'" }
}
if (result.bitmap == 0) { result.success = false; result.errors << "No valid zones" }
return result
}
String detectionRangeBitmapToPayload(int bitmap) {
return String.format("050300%02X%02X%02X", bitmap & 0xFF, (bitmap >> 8) & 0xFF, (bitmap >> 16) & 0xFF)
}
Long ledNightTimeToPayload(String timeRange) {
if (!timeRange || timeRange.trim().isEmpty()) timeRange = "21:00-09:00"
def parts = timeRange.trim().split("-")
if (parts.size() != 2) { logWarn "Invalid LED schedule format: ${timeRange}"; return null }
try {
def s = parts[0].trim().split(":"); def e = parts[1].trim().split(":")
if (s.size() != 2 || e.size() != 2) { logWarn "Invalid time in LED schedule"; return null }
int sH = s[0] as int, sM = s[1] as int, eH = e[0] as int, eM = e[1] as int
if (sH < 0 || sH > 23 || eH < 0 || eH > 23 || sM < 0 || sM > 59 || eM < 0 || eM > 59) {
logWarn "LED schedule time out of range"; return null
}
return (long)sH | ((long)sM << 8) | ((long)eH << 16) | ((long)eM << 24)
} catch (Exception ex) { logWarn "Failed to parse LED schedule '${timeRange}': ${ex.message}"; return null }
}
// ════════════════════════════════════════════════════════════════════════════
// INFO / STATUS EVENT
// ════════════════════════════════════════════════════════════════════════════
void sendInfoEvent(String info = null) {
if (!info || info == "clear") {
sendEvent(name: "_status_", value: "clear")
} else {
logInfo info
sendEvent(name: "_status_", value: info)
runIn(INFO_AUTO_CLEAR_PERIOD, "clearInfoEvent")
}
}
void clearInfoEvent() { sendInfoEvent("clear") }
// ════════════════════════════════════════════════════════════════════════════
// DRIVER VERSION
// ════════════════════════════════════════════════════════════════════════════
static String driverVersionAndTimeStamp() { version() + " " + timeStamp() }
void checkDriverVersion() {
if (state.driverVersion != driverVersionAndTimeStamp()) {
logInfo "Updating driver version from ${state.driverVersion} to ${driverVersionAndTimeStamp()}"
state.driverVersion = driverVersionAndTimeStamp()
}
}
// ════════════════════════════════════════════════════════════════════════════
// UTILITIES
// ════════════════════════════════════════════════════════════════════════════
Integer safeToInt(val, Integer defaultVal = 0) {
return "${val}"?.isInteger() ? "${val}".toInteger() : defaultVal
}
void sendZigbeeCommands(List cmds) {
logDebug "sending Zigbee: ${cmds}"
sendHubCommand(new hubitat.device.HubMultiAction(cmds, hubitat.device.Protocol.ZIGBEE))
state.txCounter = (state.txCounter ?: 0) + 1
}
void logsOff() {
log.info "${device.displayName} debug logging disabled"
device.updateSetting("logEnable", [value: "false", type: "bool"])
}
void logDebug(String msg) { if (settings?.logEnable) log.debug "${device.displayName} ${msg}" }
void logInfo(String msg) { if (settings?.txtEnable) log.info "${device.displayName} ${msg}" }
void logWarn(String msg) { if (settings?.logEnable) log.warn "${device.displayName} ${msg}" }