I believe this is a viable driver for the FP400 over zigbee. It does not do zones so it is just doing presense inside it's detection space. It will also give you illumanance in the space. This is a AI generated driver so the code could use some cleanup. I left in the code created for diagnostic research as well incase someone wants to dig into it more. It is just commented out. If anyone wants to take this further feel free to keep digging
/*
* Aqara FP400 Zigbee - Hubitat
*
* EP 01: Basic identity, 0406 occupancy, Aqara proprietary reports
* EP 02: 0400 illuminance
* EP 03-0A: 0406 occupancy attributes; exposed individually
*
* Whole-device presence and motion are driven ONLY by EP 01 0406/0000.
* FC0B is retained as diagnostic data, not used for presence.
*/
metadata {
definition(
name: "Aqara FP400 Zigbee",
namespace: "community",
author: "OpenAI"
) {
capability "Configuration"
capability "Refresh"
capability "PresenceSensor"
capability "MotionSensor"
capability "IlluminanceMeasurement"
// attribute "fp400RawFc0b", "number"
// attribute "occupancyEp03", "string"
// attribute "occupancyEp04", "string"
// attribute "occupancyEp05", "string"
// attribute "occupancyEp06", "string"
// attribute "occupancyEp07", "string"
// attribute "occupancyEp08", "string"
// attribute "occupancyEp09", "string"
// attribute "occupancyEp0A", "string"
// command "probeBasicIdentity"
/* command "readEndpointOccupancy",
[[name: "endpoint",
type: "STRING",
description: "01 or 03 through 0A"]] */
/* command "requestActiveEndpoints"
command "requestSimpleDescriptor",
[[name: "endpoint",
type: "STRING",
description: "Two hex digits, e.g. 01"]] */
// command "showObservedTopology"
// command "logsOff"
/* command "discoverFp400Attributes",
[[name: "cluster",
type: "STRING",
description: "For example 0400 or FCC0"],
[name: "endpoint",
type: "STRING",
description: "For example 02 or 01"],
[name: "startAttribute",
type: "STRING",
description: "Start with 0000"]] */
// command "readIlluminanceLimits"
/* command "readFp400Attribute",
[[name: "cluster", type: "STRING",
description: "FCC0, FCC1, or FCC3"],
[name: "attribute", type: "STRING",
description: "Four hex digits, e.g. 00F7"]]
command "readAllFp400Attributes",
[[name: "cluster",
type: "STRING",
description: "FCC0, FCC1, FCC3, 0400, or 0406"],
[name: "endpoint",
type: "STRING",
description: "01, 02, or 03 through 0A as applicable"],
[name: "scope",
type: "ENUM",
constraints: ["safe", "all"],
description: "safe excludes opaque strings and FCC0/00EE"]] */
/* command "researchEndpoint",
[[name: "endpoint",
type: "STRING",
description: "Endpoint as two hex digits, e.g. 01 or 03"]]
command "stopEndpointResearch" */
fingerprint endpointId: "01", inClusters: "FC0A,0080,FC0C,FC0B,0406,0040,FCC3,FCC0,FCC1,0003,0000", outClusters: "000A,0019", model: "lumi.models.4447_8295", manufacturer: "Aqara", controllerType: "ZGB", deviceJoinName: "Aqara FP400 Presence Sensor"
}
preferences {
input name: "globalSensitivity",
type: "enum",
title: "Global detection sensitivity",
options: [
"0": "Low",
"1": "Medium",
"2": "High"
],
defaultValue: "1",
required: true
input name: "absenceConfirmationSeconds",
type: "number",
title: "Absence confirmation time (10–300 seconds)",
description: "How long absence must be confirmed before " +
"the device reports unoccupied. " +
"Save Preferences, then click Configure.",
range: "10..300",
defaultValue: 10,
required: true
input name: "enableDebug",
type: "bool",
title: "Detailed Zigbee logging (off after 30 minutes)",
defaultValue: false
}
}
void installed() {
log.info "Installed. Click Configure, then Refresh."
}
void updated() {
if (enableDebug) runIn(1800, "logsOff")
log.info "Preferences saved. Click Configure to apply " +
"sensitivity, absence confirmation time, and reporting."
}
void logsOff() {
device.updateSetting(
"enableDebug", [value: "false", type: "bool"]
)
log.info "Detailed Zigbee logging disabled."
}
List<String> configure() {
List<String> cmds = []
cmds += configureFp400Preferences()
// Illuminance reporting was previously confirmed successful.
cmds += zigbee.configureReporting(
0x0400, 0x0000, 0x21, 10, 300, 1000
)
// Explicitly request occupancy reporting from EP 01.
// If this Hubitat overload is unavailable, Refresh and the
// readEndpointOccupancy command will still read occupancy.
try {
cmds += zigbee.configureReporting(
0x0406, 0x0000, 0x18, 0, 300, null,
[destEndpoint: 0x01]
)
log.info "Requesting EP 01 occupancy reporting."
} catch (Exception e) {
log.warn "Could not construct EP 01 reporting request: " +
e.message
}
cmds += refresh()
return cmds
}
List<String> refresh() {
List<String> cmds = []
cmds += zigbee.readAttribute(
0x0406, 0x0000, [destEndpoint: 0x01]
)
cmds += zigbee.readAttribute(
0x0400, 0x0000, [destEndpoint: 0x02]
)
return cmds
}
List<String> readEndpointOccupancy(String endpoint) {
String ep = endpoint?.trim()?.toUpperCase()
?.replaceFirst("(?i)^0x", "")
if (!(ep ==~ /(01|0[3-9]|0A)/)) {
log.warn "Choose endpoint 01 or 03 through 0A."
return []
}
log.info "Reading occupancy on EP ${ep}"
return zigbee.readAttribute(
0x0406, 0x0000,
[destEndpoint: Integer.parseInt(ep, 16)]
)
}
List<String> probeBasicIdentity() {
List<String> cmds = []
cmds += zigbee.readAttribute(
0x0000, 0x0004, [destEndpoint: 0x01]
)
cmds += zigbee.readAttribute(
0x0000, 0x0005, [destEndpoint: 0x01]
)
return cmds
}
List<String> requestActiveEndpoints() {
String dni = device.deviceNetworkId?.toUpperCase()
if (!(dni ==~ /[0-9A-F]{4}/)) {
log.warn "Invalid device network ID: ${dni}"
return []
}
String cmd = "he raw ${dni} 0 0 0x0005 " +
"{00 ${zigbee.swapOctets(dni)}} {0x0000}"
log.info "Sending ZDO Active Endpoints request: ${cmd}"
return [cmd]
}
List<String> requestSimpleDescriptor(String endpoint) {
String dni = device.deviceNetworkId?.toUpperCase()
String ep = endpoint?.trim()?.toUpperCase()
?.replaceFirst("(?i)^0x", "")
if (!(dni ==~ /[0-9A-F]{4}/) ||
!(ep ==~ /[0-9A-F]{2}/) ||
ep == "00") {
log.warn "Enter an endpoint such as 01 or 03."
return []
}
String cmd = "he raw ${dni} 0 0 0x0004 " +
"{00 ${zigbee.swapOctets(dni)} ${ep}} {0x0000}"
log.info "Sending ZDO Simple Descriptor request: ${cmd}"
return [cmd]
}
void showObservedTopology() {
log.info "Manufacturer: " +
(device.getDataValue("manufacturer") ?: "(not stored)")
log.info "Model: " +
(device.getDataValue("model") ?: "(not stored)")
List endpoints = (state.fp400ZdoEndpoints ?: []) as List
log.info "ZDO endpoints: " +
(endpoints ? endpoints.join(", ") : "(not retrieved)")
Map descriptors = (state.fp400Descriptors ?: [:]) as Map
descriptors.keySet().sort().each { ep ->
Map d = descriptors[ep] as Map
log.info "EP ${ep}: profile=${d.profile}, " +
"device=${d.device}, " +
"inClusters=${d.inClusters}, " +
"outClusters=${d.outClusters}"
}
}
void parse(String description) {
if (description?.startsWith("read attr -") &&
description.contains("cluster: FCC0") &&
description.contains("attrId: 00F7")) {
log.info "FP400 00F7 read response: ${description}"
}
if (parseIlluminanceResponse(description)) return
if (description?.startsWith("read attr -") &&
description.contains("cluster: 0406") &&
description.contains("endpoint: 01")) {
log.info "FP400 EP01 occupancy response: ${description}"
}
if (description?.startsWith("read attr -") &&
description.contains("cluster: 0400") &&
description.contains("endpoint: 02")) {
log.info "FP400 illuminance response: ${description}"
}
if (enableDebug) log.debug "Zigbee RX: ${description}"
captureBasicIdentity(description)
Map msg
try {
msg = zigbee.parseDescriptionAsMap(description)
} catch (Exception e) {
log.warn "Could not parse Zigbee message: ${e.message}"
return
}
if (!msg) return
// Observe research replies without preventing normal event processing.
handleEndpointResearchResponse(msg, description)
Integer cluster = hexInt(
msg.clusterInt != null ? msg.clusterInt :
(msg.clusterId != null ? msg.clusterId : msg.cluster)
)
if (cluster == null) return
Integer profile = hexInt(msg.profileId)
if (profile == 0x0000 &&
(cluster == 0x8005 || cluster == 0x8004)) {
log.info "ZDO response: ${description}"
decodeZdoResponse(cluster, msg.data)
return
}
/* if (cluster == 0xFCC0 &&
hexInt(msg.command) == 0x01) {
log.info "FP400 FCC0 Read Attributes response: ${description}"
} */
if (cluster in [0xFCC0, 0xFCC1, 0xFCC3] &&
hexInt(msg.command) == 0x01) {
log.info "FP400 manufacturer read response: ${description}"
}
Integer attr = hexInt(
msg.attrInt != null ? msg.attrInt : msg.attrId
)
if (hexInt(msg.command) in [0x0D, 0x0B]) {
log.info "FP400 ZCL discovery/default response: ${description}"
}
String ep = endpointString(
msg.endpoint ?: msg.sourceEndpoint
)
if (cluster == 0x0406 && attr == 0x0000) {
Integer raw = hexInt(msg.value)
if (raw != null && ep != null) {
boolean occupied = (raw & 0x01) != 0
String occupancy = occupied ?
"occupied" : "unoccupied"
if (ep == "01") {
String presence = occupied ?
"present" : "not present"
String motion = occupied ?
"active" : "inactive"
if (device.currentValue("presence")
?.toString() != presence) {
log.info "FP400 whole-room occupancy: " +
"${occupancy} (EP 01)"
}
sendChanged("presence", presence)
sendChanged("motion", motion)
} else if (ep in [
"03", "04", "05", "06",
"07", "08", "09", "0A"
]) {
String name = "occupancyEp${ep}"
if (device.currentValue(name)
?.toString() != occupancy) {
log.info "FP400 EP ${ep}: ${occupancy}"
}
sendChanged(name, occupancy)
}
}
}
if (cluster == 0x0400 && attr == 0x0000) {
Integer measured = littleEndian16(msg.value)
if (measured != null && measured != 0xFFFF) {
long lux = measured == 0 ? 0L :
Math.round(
Math.pow(
10.0d,
(measured - 1) / 10000.0d
)
)
sendChanged("illuminance", lux, "lx")
}
}
if ((cluster == 0xFC0B || cluster == 0xFC0C) &&
hexInt(msg.manufacturerId) == 0x115F &&
msg.data instanceof Collection) {
if (cluster == 0xFC0B &&
hexInt(msg.command) == 0x8B) {
handleFc0bDiagnostic(msg.data)
}
}
}
private void handleFc0bDiagnostic(Object data) {
List<Integer> bytes
try {
bytes = data.collect { hexInt(it) }
} catch (Exception ignored) {
return
}
if (bytes.size() == 3 &&
bytes[0] == 0 &&
bytes[1] == 0 &&
bytes[2] != null) {
// Deliberately does NOT set presence or motion.
// sendChanged("fp400RawFc0b", bytes[2])
}
}
private Integer littleEndian16(Object value) {
if (value == null) return null
// Hubitat's logged ZCL value is in wire byte order:
// F431 represents 0x31F4.
String text = value.toString()
.trim()
.replaceFirst("(?i)^0x", "")
if (!(text ==~ /(?i)[0-9a-f]{4}/)) return null
return Integer.parseInt(
text.substring(2, 4) + text.substring(0, 2),
16
)
}
private void captureBasicIdentity(String description) {
if (!description?.startsWith("read attr -")) return
Map<String, String> fields = [:]
description.split(/,\s*/).each { part ->
int colon = part.indexOf(":")
if (colon >= 0) {
fields[part.substring(0, colon).trim()] =
part.substring(colon + 1).trim()
}
}
if (fields.endpoint != "01" ||
fields.cluster != "0000" ||
!fields.encoding?.equalsIgnoreCase("42") ||
fields.command != "01") {
return
}
String key
if (fields.attrId == "0004") {
key = "manufacturer"
} else if (fields.attrId == "0005") {
key = "model"
} else {
return
}
String text = decodeZclCharString(fields.value)
if (!text) return
if (device.getDataValue(key) != text) {
device.updateDataValue(key, text)
log.info "Saved FP400 ${key}: ${text}"
}
}
private void decodeZdoResponse(Integer cluster, Object data) {
if (!(data instanceof Collection)) {
log.warn "ZDO response has no parsed data list."
return
}
List<Integer> b
try {
b = data.collect { hexInt(it) }
} catch (Exception ignored) {
return
}
if (!b || b.any { it == null }) return
Integer expectedNwk = hexInt(device.deviceNetworkId)
// Handle data with or without a leading transaction byte.
for (int offset : [0, 1]) {
if (b.size() < offset + 4) continue
int status = b[offset]
int nwk = b[offset + 1] | (b[offset + 2] << 8)
if (status != 0 || nwk != expectedNwk) continue
if (cluster == 0x8005) {
int count = b[offset + 3]
if (b.size() != offset + 4 + count) continue
List<String> endpoints = []
for (int i = 0; i < count; i++) {
endpoints.add(
String.format("%02X", b[offset + 4 + i])
)
}
state.fp400ZdoEndpoints = endpoints
log.info "Decoded ZDO endpoints: " +
endpoints.join(", ")
return
}
if (cluster == 0x8004) {
int length = b[offset + 3]
int p = offset + 4
if (length < 8 ||
b.size() != p + length) {
continue
}
String endpoint = String.format("%02X", b[p])
String profileId = String.format(
"%04X", b[p + 1] | (b[p + 2] << 8)
)
String deviceId = String.format(
"%04X", b[p + 3] | (b[p + 4] << 8)
)
int inCount = b[p + 6]
int index = p + 7
if (index + inCount * 2 + 1 > b.size()) {
continue
}
List<String> inClusters = []
for (int i = 0; i < inCount; i++) {
inClusters.add(String.format(
"%04X",
b[index] | (b[index + 1] << 8)
))
index += 2
}
int outCount = b[index++]
if (index + outCount * 2 != b.size()) continue
List<String> outClusters = []
for (int i = 0; i < outCount; i++) {
outClusters.add(String.format(
"%04X",
b[index] | (b[index + 1] << 8)
))
index += 2
}
Map descriptors =
(state.fp400Descriptors ?: [:]) as Map
descriptors[endpoint] = [
profile: profileId,
device: deviceId,
inClusters: inClusters,
outClusters: outClusters
]
state.fp400Descriptors = descriptors
endpointResearchDescriptorReceived(endpoint, descriptors[endpoint] as Map)
log.info "Decoded descriptor EP ${endpoint}: " +
"inClusters=${inClusters}, " +
"outClusters=${outClusters}"
return
}
}
log.warn "Could not validate ZDO response: ${data}"
}
private String decodeZclCharString(Object value) {
if (value == null) return null
String hex = value.toString()
.trim()
.replaceFirst("(?i)^0x", "")
if (!(hex ==~ /(?i)[0-9a-f]+/) ||
hex.length() < 2) {
return null
}
int length = Integer.parseInt(
hex.substring(0, 2), 16
)
if (length == 0xFF ||
hex.length() != 2 + length * 2) {
return null
}
byte[] bytes = new byte[length]
for (int i = 0; i < length; i++) {
bytes[i] = (byte) Integer.parseInt(
hex.substring(2 + i * 2, 4 + i * 2),
16
)
}
return new String(bytes, "UTF-8")
}
private Integer hexInt(Object value) {
if (value == null) return null
if (value instanceof Number) return value.intValue()
String text = value.toString()
.trim()
.replaceFirst("(?i)^0x", "")
if (!(text ==~ /(?i)[0-9a-f]+/)) return null
try {
return Integer.parseInt(text, 16)
} catch (Exception ignored) {
return null
}
}
private String endpointString(Object value) {
Integer ep = hexInt(value)
if (ep == null || ep < 0 || ep > 255) return null
return String.format("%02X", ep)
}
private void sendChanged(
String name,
Object value,
String unit = null
) {
if (device.currentValue(name)?.toString() ==
value.toString()) {
return
}
Map event = [name: name, value: value]
if (unit) event.unit = unit
sendEvent(event)
}
List<String> discoverFp400Attributes(
String cluster,
String endpoint,
String startAttribute
) {
String dni = device.deviceNetworkId?.toUpperCase()
String cid = cluster?.trim()?.toUpperCase()
?.replaceFirst("(?i)^0x", "")
String ep = endpoint?.trim()?.toUpperCase()
?.replaceFirst("(?i)^0x", "")
String start = startAttribute?.trim()?.toUpperCase()
?.replaceFirst("(?i)^0x", "")
if (!(dni ==~ /[0-9A-F]{4}/) ||
!(cid ==~ /[0-9A-F]{4}/) ||
!(ep ==~ /[0-9A-F]{2}/) ||
!(start ==~ /[0-9A-F]{4}/)) {
log.warn "Use four hex digits for cluster/startAttribute " +
"and two for endpoint."
return []
}
// ZCL Discover Attributes: command 0x0C.
// Ask for at most 16 attributes per response to keep it small.
String startWire = zigbee.swapOctets(start)
boolean aqaraCluster =
Integer.parseInt(cid, 16) >= 0xFC00
// For Aqara-specific clusters, set the manufacturer-specific
// frame-control bit and include manufacturer code 0x115F,
// little-endian as 5F 11.
String frame = aqaraCluster ?
"04 5F 11 00 0C ${startWire} 10" :
"00 00 0C ${startWire} 10"
// Hub source endpoint 01; device destination endpoint is ep.
String cmd = "he raw ${dni} 1 ${ep} 0x${cid} " +
"{${frame}} {0x0104}"
log.info "Requesting attributes: cluster=${cid}, EP=${ep}, " +
"starting=${start}; command=${cmd}"
return [cmd]
}
List<String> readIlluminanceLimits() {
List<String> cmds = []
cmds += zigbee.readAttribute(
0x0400, 0x0001, [destEndpoint: 0x02]
)
cmds += zigbee.readAttribute(
0x0400, 0x0002, [destEndpoint: 0x02]
)
return cmds
}
private boolean parseIlluminanceResponse(String description) {
if (!description?.startsWith("read attr -") ||
!description.contains("endpoint: 02") ||
!description.contains("cluster: 0400") ||
!description.contains("attrId: 0000")) {
return false
}
def match = description =~ /(?i)\bvalue:\s*([0-9a-f]{4})\b/
if (!match.find()) {
log.warn "FP400 illuminance reply has no 16-bit value: " +
description
return true
}
String wireValue = match.group(1)
Integer measured = littleEndian16(wireValue)
if (measured == null ||
measured == 0xFFFF ||
measured > 40001) {
log.warn "Ignoring invalid FP400 illuminance: " +
"wire=${wireValue}, decoded=${measured}"
return true
}
long lux = measured == 0 ? 0L :
Math.round(
Math.pow(10.0d, (measured - 1) / 10000.0d)
)
log.info "FP400 illuminance: wire=${wireValue}, " +
"decoded=${measured}, calculated=${lux} lx"
sendChanged("illuminance", lux, "lx")
return true
}
List<String> readFp400Attribute(
String cluster,
String attribute
) {
String dni = device.deviceNetworkId?.toUpperCase()
String cid = cluster?.trim()?.toUpperCase()
?.replaceFirst("(?i)^0x", "")
String aid = attribute?.trim()?.toUpperCase()
?.replaceFirst("(?i)^0x", "")
if (!(dni ==~ /[0-9A-F]{4}/) ||
!(cid in ["FCC0", "FCC1", "FCC3"]) ||
!(aid ==~ /[0-9A-F]{4}/)) {
log.warn "Use cluster FCC0/FCC1/FCC3 and a " +
"four-digit hex attribute."
return []
}
String cmd = "he raw ${dni} 1 01 0x${cid} " +
"{04 5F 11 00 00 ${zigbee.swapOctets(aid)}} " +
"{0x0104}"
log.info "Reading FP400 cluster=${cid} attribute=${aid}"
return [cmd]
}
List<String> readAllFp400Attributes(
String cluster,
String endpoint,
String scope
) {
String dni = device.deviceNetworkId?.toUpperCase()
String cid = cluster?.trim()?.toUpperCase()
?.replaceFirst("(?i)^0x", "")
String ep = endpoint?.trim()?.toUpperCase()
?.replaceFirst("(?i)^0x", "")
boolean includeAll = scope?.trim()?.equalsIgnoreCase("all")
Map<String, List<String>> ids = [
"FCC0": [
"0001", "0002", "0003", "0005", "0006", "0007",
"000C", "0012", "0016", "0017", "0020",
"0080", "0081", "00A0", "00E6", "00E8",
"00EE", "00F3", "00F6", "00F7", "00FA",
"00FC", "00FE", "00FF", "0524", "0525",
"0700", "0701", "0800", "0801", "0802",
"FFF2", "FFFD"
],
"FCC1": [
"0000", "0001", "0002", "0003",
"0004", "0005", "0006", "FFFD"
],
"FCC3": [
"0000", "0001", "0002", "0003",
"0004", "FFFD"
],
"0400": ["0000", "0001", "0002", "FFFD"],
"0406": ["0000", "0001", "0002", "0003", "0004"]
]
Map<String, List<String>> opaque = [
"FCC0": [
"0006", "0007", "000C", "0016", "0080",
"0081", "00F7", "00FE", "00FF", "0801",
"0802", "FFF2"
],
"FCC1": ["0000", "0004", "0005"],
"FCC3": ["0002", "0003"]
]
boolean validEndpoint =
(cid in ["FCC0", "FCC1", "FCC3"] && ep == "01") ||
(cid == "0400" && ep == "02") ||
(cid == "0406" &&
ep in ["01", "03", "04", "05", "06",
"07", "08", "09", "0A"])
if (!(dni ==~ /[0-9A-F]{4}/) ||
!ids.containsKey(cid) ||
!validEndpoint) {
log.warn "Invalid cluster/endpoint combination."
return []
}
List<String> selected = ids[cid].findAll { aid ->
includeAll ||
(!(aid in (opaque[cid] ?: [])) &&
!(cid == "FCC0" && aid == "00EE"))
}
if (includeAll) {
log.warn "ALL mode may read device identifiers or " +
"initialization-related data. Review and redact " +
"responses before sharing logs."
}
List<String> cmds = []
selected.each { aid ->
String frame = cid.startsWith("FC") ?
"04 5F 11 00 00 ${zigbee.swapOctets(aid)}" :
"00 00 00 ${zigbee.swapOctets(aid)}"
cmds << "he raw ${dni} 1 ${ep} 0x${cid} " +
"{${frame}} {0x0104}"
// The FP400 is USB-powered, but do not burst dozens of reads.
cmds << "delay 1000"
}
if (cmds) cmds.remove(cmds.size() - 1)
log.info "Reading ${selected.size()} discovered attributes " +
"from cluster ${cid}, EP ${ep}, scope=${includeAll ? 'all' : 'safe'}"
return cmds
}
private List<String> configureFp400Preferences() {
String sensitivityText =
settings.globalSensitivity?.toString() ?: "1"
if (!(sensitivityText in ["0", "1", "2"])) {
log.warn "Invalid sensitivity setting. Choose Low, Medium, or High."
return []
}
Integer absenceSeconds
try {
// Parsing as an integer also rejects fractional values.
absenceSeconds = Integer.parseInt(
settings.absenceConfirmationSeconds?.toString() ?: "10"
)
} catch (Exception ignored) {
log.warn "Absence confirmation time must be a whole number " +
"from 10 to 300 seconds."
return []
}
if (absenceSeconds < 10 || absenceSeconds > 300) {
log.warn "Absence confirmation time must be between " +
"10 and 300 seconds."
return []
}
int sensitivity = Integer.parseInt(sensitivityText)
String sensitivityName = [
"0": "Low",
"1": "Medium",
"2": "High"
][sensitivityText]
List<String> cmds = []
// EP 01, cluster 0080, attribute 0000.
// ZCL UINT8 (0x20), explicitly no manufacturer code.
cmds += zigbee.writeAttribute(
0x0080,
0x0000,
0x20,
zigbee.convertToHexString(sensitivity, 2),
[destEndpoint: 0x01]
)
// EP 01, cluster 0406, attribute 0003.
// ZCL UINT16 (0x21), explicitly no manufacturer code.
// Supply normal numeric hex; writeAttribute handles wire byte order.
cmds += zigbee.writeAttribute(
0x0406,
0x0003,
0x21,
zigbee.convertToHexString(absenceSeconds, 4),
[destEndpoint: 0x01]
)
// Read back both settings after writing.
cmds += zigbee.readAttribute(
0x0080, 0x0000, [destEndpoint: 0x01]
)
cmds += zigbee.readAttribute(
0x0406, 0x0003, [destEndpoint: 0x01]
)
log.info "Requesting FP400 settings: sensitivity=${sensitivityName} " +
"(${sensitivity}), absence confirmation=${absenceSeconds}s"
return cmds
}
/*
* FP400 endpoint research scanner.
*
* Requests:
* ZDO Simple Descriptor
* ZCL Discover Attributes, paginated
* ZCL Read Attributes, one attribute per request
*
* Both standard and Aqara manufacturer-specific namespaces are scanned.
* No writes, binding, or reporting configuration are performed.
*/
void researchEndpoint(String endpoint) {
String ep = endpoint?.trim()?.toUpperCase()
?.replaceFirst("(?i)^0x", "")
Integer epNumber = hexInt(ep)
String dni = device.deviceNetworkId?.toUpperCase()
if (!(ep ==~ /[0-9A-F]{2}/) ||
epNumber == null ||
epNumber < 1 ||
epNumber > 240 ||
!(dni ==~ /[0-9A-F]{4}/)) {
log.warn "Research: enter a valid endpoint as two hex digits."
return
}
stopEndpointResearch()
state.fp400Research = [
active: true,
endpoint: ep,
pending: [kind: "descriptor"],
queue: [],
discovered: 0,
readsAttempted: 0,
timeouts: 0
]
log.warn "Research starting on EP ${ep}. " +
"Read results may contain identifiers or sensitive data. " +
"Avoid other diagnostic commands until the scan finishes."
runIn(15, "endpointResearchTimeout")
try {
endpointResearchSend(requestSimpleDescriptor(ep))
} catch (Exception e) {
log.warn "Research: descriptor request failed: ${e.message}"
stopEndpointResearch()
}
}
void stopEndpointResearch() {
boolean wasActive = state.fp400Research?.active == true
unschedule("endpointResearchNext")
unschedule("endpointResearchTimeout")
state.remove("fp400Research")
if (wasActive) log.info "Endpoint research stopped."
}
private void endpointResearchSend(List<String> commands) {
if (!commands) {
throw new IllegalArgumentException("No Zigbee commands constructed")
}
sendHubCommand(
new hubitat.device.HubMultiAction(
commands,
hubitat.device.Protocol.ZIGBEE
)
)
}
/*
* Called by decodeZdoResponse() after it validates and stores a descriptor.
*/
private void endpointResearchDescriptorReceived(
String endpoint,
Map descriptor
) {
Map scan = state.fp400Research as Map
if (!scan?.active ||
scan.endpoint != endpoint ||
scan.pending?.kind != "descriptor") {
return
}
unschedule("endpointResearchTimeout")
List<String> inputClusters =
(descriptor.inClusters ?: []).collect {
it.toString().toUpperCase()
}.unique()
List<String> outputClusters =
(descriptor.outClusters ?: []).collect {
it.toString().toUpperCase()
}.unique()
log.info "Research EP ${endpoint}: server/input clusters=${inputClusters}"
log.info "Research EP ${endpoint}: client/output clusters=${outputClusters}"
// Normal client-to-server attribute requests target input clusters.
// Output clusters describe client functionality, not additional
// remotely readable server attribute tables.
List<Map> jobs = []
inputClusters.each { String cluster ->
jobs << [
kind: "discover",
cluster: cluster,
manufacturer: false,
start: 0
]
// Manufacturer-specific attributes can exist on standard
// clusters as well as on manufacturer-specific clusters.
jobs << [
kind: "discover",
cluster: cluster,
manufacturer: true,
start: 0
]
}
scan.queue = jobs
scan.pending = null
state.fp400Research = scan
runIn(1, "endpointResearchNext")
}
/*
* Public because runIn() invokes it.
*/
void endpointResearchNext() {
Map scan = state.fp400Research as Map
if (!scan?.active || scan.pending) return
List jobs = (scan.queue ?: []) as List
if (!jobs) {
log.info "Research complete for EP ${scan.endpoint}: " +
"${scan.discovered} discovered attribute entries, " +
"${scan.readsAttempted} reads attempted, " +
"${scan.timeouts} timed-out requests. " +
"Standard and manufacturer-specific entries are counted separately."
state.remove("fp400Research")
return
}
Map job = jobs.remove(0) as Map
scan.queue = jobs
scan.pending = job
if (job.kind == "read") {
scan.readsAttempted = (scan.readsAttempted ?: 0) + 1
}
state.fp400Research = scan
String namespace = job.manufacturer ? "Aqara/115F" : "standard"
if (job.kind == "discover") {
log.info "Research DISCOVER: EP=${scan.endpoint}, " +
"cluster=${job.cluster}, namespace=${namespace}, " +
"start=${String.format('%04X', (job.start as Number).intValue())}"
} else {
log.info "Research READ: EP=${scan.endpoint}, " +
"cluster=${job.cluster}, namespace=${namespace}, " +
"attribute=${job.attribute}, type=${job.dataType}"
}
runIn(15, "endpointResearchTimeout")
try {
endpointResearchSend([
endpointResearchBuildCommand(scan.endpoint.toString(), job)
])
} catch (Exception e) {
log.warn "Research request failed: ${e.message}"
endpointResearchFinishRequest()
}
}
private String endpointResearchBuildCommand(String endpoint, Map job) {
String dni = device.deviceNetworkId.toUpperCase()
// Increment ZCL transaction sequence numbers for these raw requests.
int sequence =
(((state.fp400ResearchSequence ?: 0) as Number).intValue() + 1) & 0xFF
state.fp400ResearchSequence = sequence
String tsn = String.format("%02X", sequence)
// Global command, client-to-server, default response disabled.
// Manufacturer-specific frame includes Aqara code in wire order.
String header = job.manufacturer ?
"14 5F 11 ${tsn}" :
"10 ${tsn}"
String payload
if (job.kind == "discover") {
String start = String.format(
"%04X", (job.start as Number).intValue()
)
// Discover Attributes: command 0C, maximum 16 records.
payload = "0C ${zigbee.swapOctets(start)} 10"
} else {
// Read Attributes: command 00, one attribute.
payload = "00 ${zigbee.swapOctets(job.attribute.toString())}"
}
return "he raw ${dni} 1 ${endpoint} 0x${job.cluster} " +
"{${header} ${payload}} {0x0104}"
}
/*
* Called from parse(). This method does not consume normal events.
*/
private void handleEndpointResearchResponse(
Map msg,
String description
) {
Map scan = state.fp400Research as Map
Map job = scan?.pending as Map
if (!scan?.active || !job || job.kind == "descriptor") return
Integer cluster = hexInt(
msg.clusterInt != null ? msg.clusterInt :
(msg.clusterId != null ? msg.clusterId : msg.cluster)
)
String endpoint = endpointString(
msg.endpoint ?: msg.sourceEndpoint
)
if (cluster != hexInt(job.cluster) ||
endpoint != scan.endpoint) {
return
}
Integer command = hexInt(msg.command)
List<Integer> data = null
if (msg.data instanceof Collection) {
data = msg.data.collect { hexInt(it) }
if (data.any { it == null }) data = null
}
/*
* Default Response payload:
* original command, status
*/
if (command == 0x0B && data != null && data.size() >= 2) {
int expectedCommand = job.kind == "discover" ? 0x0C : 0x00
if (data[0] != expectedCommand) return
log.info "Research DEFAULT RESPONSE: " +
"EP=${scan.endpoint}, cluster=${job.cluster}, " +
"namespace=${job.manufacturer ? 'Aqara/115F' : 'standard'}, " +
"status=${String.format('%02X', data[1])}; ${description}"
// A successful default response is not the requested data.
if (data[1] != 0) endpointResearchFinishRequest()
return
}
if (job.kind == "discover" && command == 0x0D) {
endpointResearchHandleDiscovery(scan, job, data, description)
return
}
if (job.kind != "read") return
/*
* Hubitat may deliver successful reads as "read attr -" maps
* and unsuccessful reads as raw command 01 responses.
*
* Verify the requested attribute rather than accepting an
* unrelated report from the same cluster.
*/
Integer attribute = hexInt(
msg.attrInt != null ? msg.attrInt : msg.attrId
)
Integer wanted = hexInt(job.attribute)
boolean parsedRead =
attribute == wanted &&
(command == 0x01 ||
(command == null && description?.startsWith("read attr -")))
boolean rawRead =
command == 0x01 &&
data != null &&
data.size() >= 3 &&
(data[0] | (data[1] << 8)) == wanted
if (parsedRead || rawRead) {
log.info "Research RESULT: EP=${scan.endpoint}, " +
"cluster=${job.cluster}, " +
"namespace=${job.manufacturer ? 'Aqara/115F' : 'standard'}, " +
"attribute=${job.attribute}, discoveredType=${job.dataType}; " +
"${description}"
endpointResearchFinishRequest()
}
}
private void endpointResearchHandleDiscovery(
Map scan,
Map job,
List<Integer> data,
String description
) {
/*
* Discover Attributes Response payload:
* discoveryComplete: UINT8
* repeated:
* attributeId: UINT16, little-endian
* dataType: UINT8
*/
if (data == null ||
data.size() < 1 ||
!(data[0] in [0, 1]) ||
(data.size() - 1) % 3 != 0) {
log.warn "Research: unrecognized discovery response; " +
"skipping this namespace. ${description}"
endpointResearchFinishRequest()
return
}
boolean complete = data[0] == 1
int start = (job.start as Number).intValue()
int highest = -1
List<Map> reads = []
List<String> attributes = []
Set<Integer> seen = [] as Set
for (int i = 1; i < data.size(); i += 3) {
int id = data[i] | (data[i + 1] << 8)
String aid = String.format("%04X", id)
String type = String.format("%02X", data[i + 2])
highest = Math.max(highest, id)
// Prevent duplicated or backwards pages from looping.
if (id < start || seen.contains(id)) continue
seen.add(id)
attributes << "${aid}(type=${type})"
reads << [
kind: "read",
cluster: job.cluster,
manufacturer: job.manufacturer,
attribute: aid,
dataType: type
]
}
log.info "Research ATTRIBUTES: EP=${scan.endpoint}, " +
"cluster=${job.cluster}, " +
"namespace=${job.manufacturer ? 'Aqara/115F' : 'standard'}, " +
"complete=${complete}, attributes=${attributes}"
List<Map> nextJobs = []
nextJobs.addAll(reads)
// Read this page before requesting the next one, keeping state small.
if (!complete) {
if (highest >= start && highest < 0xFFFF) {
nextJobs << [
kind: "discover",
cluster: job.cluster,
manufacturer: job.manufacturer,
start: highest + 1
]
} else {
log.warn "Research: discovery made no forward progress " +
"for cluster ${job.cluster}; stopping this namespace."
}
}
nextJobs.addAll((scan.queue ?: []) as List)
scan.queue = nextJobs
scan.discovered = (scan.discovered ?: 0) + reads.size()
state.fp400Research = scan
endpointResearchFinishRequest()
}
private void endpointResearchFinishRequest() {
Map scan = state.fp400Research as Map
if (!scan?.active) return
unschedule("endpointResearchTimeout")
scan.pending = null
state.fp400Research = scan
runIn(1, "endpointResearchNext")
}
/*
* Public because runIn() invokes it.
*/
void endpointResearchTimeout() {
Map scan = state.fp400Research as Map
Map job = scan?.pending as Map
if (!scan?.active || !job) return
scan.timeouts = (scan.timeouts ?: 0) + 1
state.fp400Research = scan
log.warn "Research TIMEOUT: EP=${scan.endpoint}, request=${job}"
if (job.kind == "descriptor") {
log.warn "Research cannot continue without the endpoint descriptor."
stopEndpointResearch()
return
}
endpointResearchFinishRequest()
}