Aqara FP400 now available. Has anyone tried it?

Getting ready to cleanup the driver and i will post the Zigbee driver AI has created for me.

That said i am considering returning the sensor. It isn't that it isn't working, but i am not sure i can justify the $80 price tag when all it can do by design is detect motion/presence, and illuminance so far.

From what i can tell the only thing Aqara uses that is standard is the presence, and illumance cluster. Everything else is well hidden and not sure i want to dig into it enough to reverse engineer it.

The multi zone stuff may be doable eventually, but without clear documentation or extensive reverse engineering it will be hard to figure out all of the setup options. It will also require a smartapp to manage them if possible.

Using GPT6-Astra to anazlyze output from all the clusters on Endpoint 1 this is what i got from it. Endpoint 2 was very limited

Below is the inventory from your **EP 01** scan: **11 server clusters and 111 discovered attribute entries**.

### How to read the tables

- Numeric values are shown in **decimal**, with multibyte values decoded from the little-endian bytes in your logs.
- `Standard` means the request did **not** include a manufacturer code.
- `Aqara` means the request included manufacturer code **`0x115F`**.
- “Unknown” means the scan establishes the data type and value, but **not the attribute’s function**.
- These are snapshots collected over approximately three minutes, not simultaneous readings.
- Ordinary Discover Attributes establishes IDs and types, **not whether attributes are writable**.

**The two reported timeouts followed parser errors.** The `0406/0004` value is visible and can be decoded manually. The `FCC1/0004` value is not present in the supplied logs.

## 1. Cluster `0x0080` — sensitivity controls

The meanings below use the information you supplied earlier, corroborated by these read values.

| Attribute | Namespace | ZCL data type | Current value | Meaning |
|---|---|---|---|---|
| `0000` | Standard | `20` UINT8 | **1 — Medium** | Global sensitivity: 0 = Low, 1 = Medium, 2 = High |
| `0001` | Standard | `20` UINT8 | **3** | Number of sensitivity levels |
| `0002` | Standard | `20` UINT8 | **1 — Medium** | Default sensitivity |
| `FFFD` | Standard | `21` UINT16 | **1** | Cluster revision |
| `FFFA` | Aqara | `48` ARRAY | ENUM16 array: **[1]** | Proprietary meaning unknown |

The `FFFA` value is:

```text
31 0100 0100
│  │    └─ Value: 1
│  └────── Element count: 1
└───────── Element type: ENUM16
```

Do not assume this array describes sensitivity options; that relationship has not been established.

## 2. Cluster `0x0406` — Occupancy Sensing

All five attributes were discovered in the **standard namespace**.

| Attribute | ZCL data type | Current value | Meaning |
|---|---|---|---|
| `0000` | `18` BITMAP8 | **0x00 — Unoccupied** | Occupancy status; bit 0 is the occupied flag |
| `0001` | `30` ENUM8 | **0** | Occupancy sensor type; standard enum value 0 denotes PIR |
| `0002` | `18` BITMAP8 | **0x01** | Occupancy sensor type bitmap; standard bit 0 denotes PIR |
| `0003` | `21` UINT16 | **10 seconds** | Absence confirmation time, based on your earlier findings |
| `0004` | `4C` STRUCT | **[10, 300, 10]**, all UINT16 | Minimum, maximum, and default absence confirmation time, based on your earlier findings |

**Important:** the PIR metadata values do not establish that the FP400’s physical sensing technology is PIR. They establish only what its Zigbee attributes advertise.

### Manually decoded `0004`

Although Hubitat’s parser failed, the response contains:

```text
0300 21 0A00 21 2C01 21 0A00
```

Decoded:

```text
Structure element count: 3
UINT16: 10
UINT16: 300
UINT16: 10
```

This was therefore **a received value that the parser could not process**, not an absent device response.

## 3. Cluster `0xFC0A` — proprietary Aqara controls/data

All 26 attributes were discovered in the **Aqara namespace**.

There is not enough information in these reads alone to assign reliable setting names to this cluster.

| Attribute | ZCL data type | Current value | Meaning |
|---|---|---|---|
| `0000` | `30` ENUM8 | **0** | Unknown enum |
| `0001` | `48` ARRAY | **Empty array**, element type `00` | Unknown |
| `0002` | `30` ENUM8 | **0** | Unknown enum |
| `0003` | `48` ARRAY | **Empty array**, element type `00` | Unknown |
| `0004` | `21` UINT16 | **0** | Unknown |
| `0005` | `21` UINT16 | **0** | Unknown |
| `0006` | `21` UINT16 | **0** | Unknown |
| `0007` | `30` ENUM8 | **1** | Unknown enum |
| `0008` | `20` UINT8 | **15** | Unknown |
| `0010` | `48` ARRAY | **Empty array**, element type `00` | Unknown |
| `0011` | `20` UINT8 | **8** | Unknown |
| `0012` | `41` OCTET STRING | **Empty** | Unknown binary payload |
| `0013` | `41` OCTET STRING | **Empty** | Unknown binary payload |
| `0014` | `41` OCTET STRING | **Empty** | Unknown binary payload |
| `0015` | `21` UINT16 | **1** | Unknown |
| `0016` | `21` UINT16 | **20** | Unknown; units not established |
| `0023` | `10` BOOLEAN | **false** | Unknown flag |
| `0029` | `10` BOOLEAN | **false** | Unknown flag |
| `002A` | `10` BOOLEAN | **false** | Unknown flag |
| `002B` | `10` BOOLEAN | **false** | Unknown flag |
| `002C` | `10` BOOLEAN | **false** | Unknown flag |
| `002D` | `30` ENUM8 | **0** | Unknown enum |
| `002E` | `30` ENUM8 | **0** | Unknown enum |
| `002F` | `30` ENUM8 | **0** | Unknown enum |
| `FFFA` | `48` ARRAY | ENUM16 array: **[0, 1, 2, 3]** | Proprietary meaning unknown |
| `FFFD` | `21` UINT16 | **1** | Cluster revision |

The value **8** in `0011` is interesting given the eight additional occupancy endpoints, but **that numerical match alone does not establish that it is a zone count**.

## 4. Cluster `0xFC0B` — proprietary Aqara data

All four attributes were discovered in the **Aqara namespace**.

| Attribute | ZCL data type | Current value | Meaning |
|---|---|---|---|
| `0000` | `48` ARRAY | **Empty UINT16 array** | Unknown |
| `0002` | `21` UINT16 | **0** | Unknown |
| `FFFA` | `48` ARRAY | ENUM16 array: **[0, 1, 2, 3, 4, 5]** | Proprietary meaning unknown |
| `FFFD` | `21` UINT16 | **1** | Cluster revision |

These attribute reads do **not** establish the meaning of the separate `FC0B` command `0x8B` that your driver retains as diagnostic traffic.

Keep whole-device presence driven by `0406/0000`, as your driver currently does.

## 5. Cluster `0xFC0C` — proprietary Aqara data

All four attributes were discovered in the **Aqara namespace**.

| Attribute | ZCL data type | Current value | Meaning |
|---|---|---|---|
| `0000` | `20` UINT8 | **10** | Unknown; units not established |
| `0007` | `30` ENUM8 | **0** | Unknown enum |
| `FFFA` | `48` ARRAY | ENUM16 array: **[0]** | Proprietary meaning unknown |
| `FFFD` | `21` UINT16 | **1** | Cluster revision |

The value `10` must not yet be treated as another absence timeout merely because it matches the confirmed timeout.

## 6. Cluster `0x0040` — version-related data observed

Both attributes were discovered in the **standard namespace**.

| Attribute | ZCL data type | Current value | Meaning |
|---|---|---|---|
| `0000` | `48` ARRAY | One STRUCT containing **["mcu-version", "60"]** | Payload explicitly identifies an MCU version value |
| `FFFD` | `21` UINT16 | **1** | Cluster revision |

Decoded `0000`:

```text
ARRAY
  Element type: STRUCT
  Element count: 1

  STRUCT
    Element count: 2
    CHAR STRING: "mcu-version"
    CHAR STRING: "60"
```

The version value here is the **string `"60"`**, not a binary integer.

## 7. Cluster `0xFCC3` — proprietary Aqara data

All six attributes were discovered in the **Aqara namespace**.

| Attribute | ZCL data type | Current value | Meaning |
|---|---|---|---|
| `0000` | `23` UINT32 | **16** | Unknown |
| `0001` | `21` UINT16 | **0** | Unknown |
| `0002` | `48` ARRAY | **Empty array**, element type `00` | Unknown |
| `0003` | `41` OCTET STRING | **Empty** | Unknown binary payload |
| `0004` | `39` FLOAT32 | **0.0** | Unknown; units not established |
| `FFFD` | `21` UINT16 | **4** | Cluster revision |

## 8. Cluster `0xFCC0` — proprietary Aqara device data

All 33 attributes were discovered in the **Aqara namespace**.

For binary strings, the table shows the payload **without its ZCL length prefix**.

| Attribute | ZCL data type | Current value | Meaning |
|---|---|---|---|
| `0001` | `21` UINT16 | **0** | Unknown |
| `0002` | `21` UINT16 | **16** | Unknown |
| `0003` | `20` UINT8 | **2** | Unknown |
| `0005` | `20` UINT8 | **1** | Unknown |
| `0006` | `41` OCTET STRING | **16 bytes — see below** | Opaque binary data; potentially sensitive |
| `0007` | `41` OCTET STRING | **8 bytes — see below** | Opaque binary data; potentially identifying |
| `000C` | `41` OCTET STRING | **Empty** | Unknown binary payload |
| `0012` | `20` UINT8 | **1** | Unknown |
| `0016` | `42` CHAR STRING | **64-character hexadecimal-looking string — see below** | Unknown; potentially sensitive |
| `0017` | `23` UINT32 | **12** | Unknown |
| `0020` | `23` UINT32 | **0** | Unknown |
| `0080` | `41` OCTET STRING | **Empty** | Unknown; distinct from cluster `0080` |
| `0081` | `41` OCTET STRING | **Empty** | Unknown binary payload |
| `00A0` | `20` UINT8 | **0** | Unknown |
| `00E6` | `20` UINT8 | **0** | Unknown |
| `00E8` | `10` BOOLEAN | **false** | Unknown flag |
| `00EE` | `23` UINT32 | **60** | Unknown; matches Basic application version numerically |
| `00F6` | `21` UINT16 | **300** | Unknown; units not established |
| `00F3` | `20` UINT8 | **1** | Unknown |
| `00F7` | `41` OCTET STRING | **Empty** | No payload returned by this read |
| `00FA` | `10` BOOLEAN | **false** | Unknown flag |
| `00FC` | `10` BOOLEAN | **false** | Unknown flag |
| `00FE` | `42` CHAR STRING | **32 bytes of `FF`** | Not ordinary readable text; possibly an unset/sentinel value |
| `00FF` | `41` OCTET STRING | **Empty** | Unknown binary payload |
| `0700` | `10` BOOLEAN | **false** | Unknown flag |
| `0701` | `28` INT8 | **10** | Unknown signed value |
| `0524` | `20` UINT8 | **12** | Unknown |
| `0525` | `20` UINT8 | **10** | Unknown |
| `0800` | `10` BOOLEAN | **false** | Unknown flag |
| `0801` | `41` OCTET STRING | **Empty** | Unknown binary payload |
| `0802` | `41` OCTET STRING | **Empty** | Unknown binary payload |
| `FFF2` | `41` OCTET STRING | **Empty** | Unknown binary payload |
| `FFFD` | `21` UINT16 | **1** | Cluster revision |

### Opaque values

These are reproduced from your logs for completeness. **Redact them before publishing the inventory** unless their purpose is established.

```text
FCC0/0006 — OCTET STRING, 16 bytes
B972EC17B20FABCB81A1A2AC1DCB4570

FCC0/0007 — OCTET STRING, 8 bytes
000000003694C2EC

FCC0/0016 — CHAR STRING, 64 characters
CE47E723FC1FFE8F996FF83FC2BE50DF03EB847D788C0DC65B0199086C05B602
```

An empty `00F7` read does not prove that unsolicited `00F7` reports will always be empty.

## 9. Cluster `0xFCC1` — proprietary Aqara device information/data

All eight attributes were discovered in the **Aqara namespace**.

| Attribute | ZCL data type | Current value | Meaning |
|---|---|---|---|
| `0000` | `42` CHAR STRING | **"Spatial Multi-Sensor FP400"** | Human-readable product name |
| `0001` | `30` ENUM8 | **2** | Unknown enum |
| `0002` | `10` BOOLEAN | **false** | Unknown flag |
| `0003` | `10` BOOLEAN | **false** | Unknown flag |
| `0004` | `43` LONG OCTET STRING | **Unavailable in supplied logs** | Parser exception occurred following this read |
| `0005` | `43` LONG OCTET STRING | **Empty** | Zero-length binary payload |
| `0006` | `23` UINT32 | **0** | Unknown |
| `FFFD` | `21` UINT16 | **3** | Cluster revision |

For `0004`, the error was:

```text
Could not parse Zigbee message: begin 4, end 1118, length 142
```

This is not enough to reconstruct the value or determine whether the underlying issue is malformed data, truncation, or Hubitat’s handling of the long-string encoding.

## 10. Cluster `0x0003` — Identify

All three attributes were discovered in the **standard namespace**.

| Attribute | ZCL data type | Current value | Meaning |
|---|---|---|---|
| `0000` | `21` UINT16 | **0 seconds** | IdentifyTime: no active identify countdown |
| `0001` | `30` ENUM8 | **2** | IdentifyType: standard value for a visible indicator |
| `FFFD` | `21` UINT16 | **1** | Cluster revision |

The descriptor and attributes establish an Identify server, but the scan did not exercise an Identify command or determine which physical indicator it uses.

## 11. Cluster `0x0000` — Basic identity

All 15 attributes were discovered in the **standard namespace**.

| Attribute | ZCL data type | Current value | Meaning |
|---|---|---|---|
| `0000` | `20` UINT8 | **3** | ZCLVersion |
| `0001` | `20` UINT8 | **60** (`0x3C`) | ApplicationVersion; manufacturer-defined version numbering |
| `0002` | `20` UINT8 | **27** (`0x1B`) | StackVersion; manufacturer-defined version numbering |
| `0003` | `20` UINT8 | **1** | HardwareVersion |
| `0004` | `42` CHAR STRING | **"Aqara"** | ManufacturerName |
| `0005` | `42` CHAR STRING | **"lumi.models.4447_8295"** | ModelIdentifier |
| `0006` | `42` CHAR STRING | **"20260915"** | DateCode; appears to represent 2026-09-15 |
| `0007` | `30` ENUM8 | **4 — DC source** | PowerSource |
| `000A` | `41` OCTET STRING | **`00 41 53 30 37 38`** | ProductCode; payload contains a leading zero byte followed by ASCII `"AS078"` |
| `000D` | `42` CHAR STRING | **32 bytes of `FF`** | SerialNumber attribute, but no readable serial number returned |
| `0010` | `42` CHAR STRING | **"默认房间" followed by NUL** | LocationDescription; Chinese text means “Default room” |
| `F000` | `21` UINT16 | **0** | Vendor-defined attribute; meaning unknown |
| `F001` | `20` UINT8 | **0** | Vendor-defined attribute; meaning unknown |
| `F003` | `42` CHAR STRING | **Empty** | Vendor-defined attribute; meaning unknown |
| `FFFD` | `21` UINT16 | **2** | Cluster revision |

The application version **60** agrees numerically with the `"mcu-version"` string from `0040/0000`. That is useful corroboration, but it does not define how Aqara maps this number to a marketed firmware version.

## Output/client clusters

The Simple Descriptor also lists:

| Cluster | Standard function | Scan result |
|---|---|---|
| `0019` | OTA Upgrade | Advertised as a client/output cluster; not attribute-scanned |
| `000A` | Time | Advertised as a client/output cluster; not attribute-scanned |

These indicate client functionality on EP 01, not additional discovered server attribute tables.

## Main conclusions

1. **Your two preferences are consistent with the readings:** sensitivity is **Medium**, and absence confirmation is **10 seconds**.
2. **`FC0A` is a substantial additional research target**, with 26 advertised attributes, but their user-facing meanings remain unverified.
3. **Do not infer functions from matching numbers.** For example, `FCC0/00F6 = 300` is not yet evidence of a timeout or limit.
4. **The parser needs a raw-response fallback** for structured and long-string reads before the scanner’s “timeout” count can be treated as a count of missing device responses.
5. **Discovery responses are not consistently sorted.** Your `FCC0` pages contain out-of-order IDs, and some other clusters put `FFFD` first. The scanner read all entries shown here, but its maximum-ID pagination strategy cannot guarantee exhaustive discovery if this firmware places lower IDs on later pages.

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()
}

We need someone with the Hubitat C8 Pro Thread edition to test it out using matter over thread. It looks like there may be expanded functionality with the Thread Boarder Router version of Hubitat.

It does seem to work well as a Motion sensor, but $80 is a bit steep for a motion sensor.

I am considering getting either the Aqara M100 hub or M3 to fully enable the device's potential and then also connect my FP300 as well. but not sure it makes much sense. :face_with_raised_eyebrow:

I went down a rabit hole tonight with AI and this FP400 Sensor.

I took my dev hub and connected my hermes agent to it's MCP server. Then gave it access to my Live Logging. Then paired the FP400 to it. At last i let it loose on trying to configure everything for the device, even for things that aren't documented. The thought being it could tinker with the device until it figured it out.

It got pretty far, i think it has pretty much everything except for the how to setup Zones. Aqara did a really good job at making that hard to figure out. Unfortunately without that pretty much everything that has been figured out doesn't matter.

I have a Aqara M100 thread boarder router in route. Tomorrow I may be able to take this further.

The M100 is their cheap limited device hub so it may give me the ability to validate all the configuration stuff. We will see.

The entire premise, for me, is to use the multi-zone of this (same as with the FP2 sensors I have) to do different things in different areas of rooms. I have an open family/dining/kitchen area, and I can adjust lights in the different areas using one sensor. I see it as more than a single motion sensor, and agree that $80 is stiff for a single device.

I realize that I can do it the "olde fashioned" way, where I add it to HomeKit, which for some reason DOES understand that multiple zones, and bring that across into Hubitat, rather than using the device natively. I did that with my FP2 before there was a driver for it. Perhaps it's just a matter (no pun intended) of time.

Either way, I thank you for your work on this!

Did you do it as a thread device or a zigbee device?

I suspect if you have the C8Pro thread edition. You can configure the zones when added as thread. It would be nice if someone with that hub could configure it.

The big problem is Aqara doesn't publish anything for their Aqara specific Clusters on zigbee. I have tried a few things to get more details with AI. I am to the point I really think I need a Zigbee Sniffer to extract the actual transmissions. Then I could get the AI agent to test everything in the Aqara app and decipher it. I have spent probably $15 just in AI credits on this already. With Luna that is allot of calls. Between the sensor, the M100, and the AI, this is adding up.

My agent has built a virtual Android device so it can test certain things from the Aqara app itself. This came about trying to figure out what the app sends to the cloud/hub to set the zones, come to find out it doesn't send anything to the M100. It looks like the zones go to the cloud and then back to the M100. That is why a sniffer is needed for zigbee at this point.

The Aqara FP2 is a WiFi only device that is able to be directly paired to either Apple Home via HomeKit over WiFi OR directly to a C8 Pro hub via the built-in HomeKit Controller Integration. Since the HomeKit Accessory Protocol (HAP) can only be paired to one controller at a time, the user had to choose one or the other platform. In my case, I actually used an instance of Home Assistant to bring the Aqara FP2 in via HomeKit, and then used HADB to bring in each FP2 Motion Zone and the Illuminance into Hubitat.

The Aqara FP400 is pretty much identical to the Aqara FP2 in terms of its sensor feature set. However, it is not a WiFi device and thus no longer natively supports the HomeKit Accessory Protocol (HAP). The FP400's built-in radio support either Matter over Thread (MoT) or Zigbee. It can also talk to the Aqara Mobile phone app via Bluetooth to change the firmware from Thread to Zigbee and vice versa, as well as for initial commisioning (i.e. pairing) to a Matter Controller via a Thread Border Router (TBR). Zigbee pairing is pretty straightforward.

If the FP400 is paired directly to a non-Aqara Hub Zigbee Controller, like a Hubitat Hub, it will simply provide Motion and Illuminance. This is what @mavrrick58 has been working on and sounds like he has a working Zigbee driver for Hubitat. Unfortunately, Aqara does not release any documentation regarding their custom Zigbee implementation, and thus everything has to be reverse engineered. Hopefully, the multi-zone functionality will be figured out eventually.

If the FP400 is paired as a Matter over Thread device, then all of the individual Zones and Illuminance values are available. I have my FP400 paired via Thread to an Aqara M3 hub, and then I shared this as a Matter device to my HE hub. The HE hub does NOT need a Thread radio for this to work, as the Aqara M3 hub is acting as the TBR, along with my AppleTV 4K and HomePod Mini. When added to the HE hub via Matter, I see a main FP400 device with the overall Presence (i.e. Motion) and Illumnance. This Parent device has a Child device created for each of the manually created FP400 'Zones'. Here is what it looks like on my HE hub.

I created two custom Zones in the Aqara mobile app for the FP400 during my testing. As you can see above, both child devices were created successfully. I did have to manually name them as that info did not come across.

This post over in the Aqara Community does a decent job of explaining the various methods of connecting the FP400, along with the Pros and Cons of each approach. Hubitat is not mentioned in that post, but you can still get a decent idea of how it behaves.

I have paired my M100 as a Matter bridge and when it setup my 2 aqara devices it lost the illuminance attribute. Is there a generic Componet Illuminance device? That may be a part that is missing.

Do you have the FP400 connected to you Aqara Hub via Zigbee, and then exposed to Hubitat via the Aqara Hub’s Matter Bridge functionality? If so, that might explain why Illuminance was lost.

Try pairing the FP400 to the Aqara hub via Thread, then share the FP400 as a Matter device (not the Matter Bridge) to the HE hub. That is the config I have found works best, as I described above. This assumes your Aqara hub has a Thread radio, though…:thinking: Update: just verified that the M100 does have a Thread radio.

FYI - Looks like the FP400 is on sale today at Amazon for about $68.

When I had the FP400 connected directly to Hubitat over Zigbee i had illumance and overall presense detection.

After I got the M100 in I connected the FP400 to the M100 hub over zigbee. Then paired it to hubitat as a matter bridge and i got multizone, but lost illumance. I checked for the Generic Component driver and there doesn't appear to be one for illumance.

After that i tried to switch to thread and pair again. The FP400 completes the switch to Mater over Thread, and then no matter what i do it won't connect to the M100 hub. I tried sevearl things to try to reset the sensors and the m100, my phone, cleared the app data/cache, but i can't get it to work.

Does pairing the M100 with matter to Hubitat mess with the Matter network on the M100.

Only thing left i can try now is to lock down my wifi network to 2.4ghz to see if somehow that is impacting it somehow. Would be frustrating if it is since it is the same network behind the scenes it is all on.

I am getting burnt out on trying to figure this out. Really need a sniffer at this point.

Not sure that I understand the question exactly, but I'll try...

The Aqara Hubs with TBR support will attempt to add their Thread radio to an existing Thread Mesh Network if your phone is aware of one. For example, my Aqara M3's Thread radio became part of my existing Apple Home's Thread mesh network, as my iPhone was already aware of the existing Apple TBRs (AppleTV 4Ks and HomePod Mini.)

However, when a device is 'shared' from one Matter Controller to another Matter Controller (e.g. the FP400 paired as a MoT device on my Aqara M3 Hub --> Hubitat C8 Pro hub), the two Matter Controllers do not 'mess' with one another. In this example, the Hubitat hub will talk Matter over Ethernet/WiFi to the M3's TBR which then talks via Thread to the FP400 sensor.

When pairing the FP400 via Thread to your M100 hub, I assume you have tried factory resetting the FP400 by pressing its button 10 times quickly? Then, when adding the device to your Aqara hub, you should allow the Aqara Mobile app to discover the FP400. It will ask if you want to use Thread or Zigbee, and once Thread is selected will change the FP400's firmware over Bluetooth which takes about 10 minutes or so. At that point, one has to start the discovery process again, IIRC... This time, when Thread is chosen it should allow you to add the device to your M100 hub as a Matter device. There are two different QR codes on the FP400, one for Matter and the other is an 'Aqara' code. If prompted to scan a code, be sure to use the Matter QR code. It has been a little while since I have gone through this process, so hopefully I have remembered the steps correctly.

Once the FP400 is successfully added and configured for zones on the Aqara hub, you should be able to go into the Aqara app and share this specific device with another Matter controller (i.e. the Hubitat hub.)

If you can't get this to work, let me know and I'll try going through the process again to see if I forgot anything.

I got some time this evening to try out your directions.

The procedure didn't work, but it was a unifi configuration i needed to adjust for IPv6. I am attaching the video that helped me find it. Simply put my IPv6 configuration was having a problem and i needed to adjust my network to use a static prefex instead of use the one from the ISP. The ISP wasn't providing a IPv6 address so the prefix was invalid and causing a routing issue.

Cool! Thanks for sharing the 'fix' for others who will undoubtedly stumble upon this thread (no pun intended :wink: ) in the future.

So, with the IPv6 issue sorted out on your network, were you able to successfully pair the FP400 with the M100 hub via Thread, and then share the FP400 with your Hubitat Hub?

I have IPv6 simply disabled on my UniFi Home LAN.

This allows the Matter/Thread devices to simply create their own private IPv6 network and everything "just works" by default.

I can see how a misconfigured IPv6 home network could interfere with Matter over WiFi (MoW) and Matter over Thread (MoT) controllers, devices, and TBRs. It really makes Zigbee and Z-Wave seem much simpler and easier to maintain, eh? :wink:

So after digging trough this I have come to these conclusions.

First there are 4 ways to connect the device.

  1. Zigbee straight to Hubitat
  2. Zigbee to Aqara hub like a M100 or M3, then share the Aqara hub as a Matter bridge device.
  3. Thread to Aqara hub acting as Thread border router and then shared as Matter directly to Hubitat
  4. Thred Directly to the Hubitat C8 Pro Thread Edition.

Each option has it's pros and cons

Option 1 above is pretty straight forward, but unfortunately requires a driver that doesn't exist no the hub already. The only things exposed with non proprietary Zigbee clusters are the general occupancy of the sensor and Illumination. To expose the other aspects requires reverse engineering the Zigbee communication with a sniffer to clearly identify the clusters and data format of the calls. This means Zone setup as well as many configuration options are not easily setup. This is a suboptimal option

Option 1 provides General Occpancy and illuminance

Option 2 is pretty straight forward. When setting up the device switch it to Zigbee and pair it to the Aqara Hub. Then pair the Aqara Hub with Matter to the Hubitat hub that supports Matter. Once Paired the Hubitat hub detects the Aqara hub as a Matter bridge and detects the endpoints shared from the Aqara hub and creates child virtual devices for each attribute shared. It works as long as there is a generic driver for the attribute and the attribute is shared. For devices that support Multi-zones each zone gets its own virtual device through the Matter bridge and as such hubitat sees them. This seems to have worked well except for illumance was missing. It appears that there isn't a generic driver for that attribure which may be the reason for it missing. Fairly reasonable option, with illuminance not being avaliable.

Option 2 provides General occupancy, and Zone based control

Option 3 is the most interesting to me. When performing the setup use thread and pair directly with the Aqara hub you have. Once connected, the Aqara hub acts as the thread border router and you can pair each device to Hubitat using the matter share code. This essentially makes the Aqara Hub a middle man for the ip Matter network based communication to the thread network. The big difference here is that the devices don't automatically share to the Hubitat Hub. They are seen as Matter devices and as such don't have a parent device when setup. The Thread Boarder router is invisiable to the hub. This is the most feature rich of all the options

Option 3 Provides General Occpancy, Illumance, and Zone control

Option 4 is one i don't have a way to test as I don't have a C8P thread edition. I can make some assumptions based on information i have read. There are articles that describe a way to connect the sensor directly to another thread network. The documentation conveys that you should be able to pair the device directly to a C8Pro Thread edition and also continue to use the Aqara Home app to configured the device. If this works it will likely use bluetooth to control the zones on the device. That said i have concerns it would suffer the same issues as Option 1 in that it would need a driver written to be fully utilized. This is a situation that really needs to be validated by someone with such a hub.

Option 4 Provides in theory General Occpancy, Illumance, and Zone control if a driver can enable it.

In every case were zones are functional it is controlled/setup from the Aqara app.

Honestly my suggestion after trying this out would be to use a Aqara Thread Border router hub at this point. They basically have 3 options for this condition

  1. M100 is the one i got. It is limited to zigbee and 20 Matter over thread devices.
  2. M200 is limited to 40 zigbee and 40 Matter over thread devices.
  3. M3 Is mentioned by several folks. It is limited to 127 zigbee and 127 Matter over thread devices.

The big cons to all of these devices is that they are designed for zigbee to only work with Aqara branded devices. The M100 and M200 are also not 100% local if you do automations on them. The M100 is wifi based and is powered by a old style USB-a connection. The M200 and M3 can use USB-c or POE to power the device.

Right now i can't seem myself going beyond 20 devices and needing more then the M100 to basically be a TBR. It is hard to beat the price m100 when its MSRP is $30 unless you are planning a large thread deployment. Then I would probably suggest the M3.

One thing i am not clear on based on what i read is use of Multiple hubs in a Thread environment. It sounds like you could run multiple of the M100 as Thread Border Routers to create redundancey. Has anyone tried that.

I picked up an FP400 and a M100 Hub. Got the FP400 up and running on the Hub and was able to connect the M100 to Hubitat as a Matter Bridge.

So far all the Zones populate on Hubitat, update when I add new ones and trigger very quickly.

I am having trouble figuring out how to utilize the Lux (light) sensor from the Fp400 over to Hubitat.

I thought i could change the detection zone to have one share Lux information to a Matter Motion and Light Sensor. Unfortunately I am unable to change any of the child devices.
Does anyone have any suggestions?
I see in @ogiewon post that a FP400 is listed as a device. I only have the Hub and then my zones as devices.

I see where my setup might be different....I pair to M100 via Zigbee and then shared paired the M100 as the device.

Do you get all the zones in that fashion? My understanding was that using Thread lost some of the functionality of the Fp400.

Yes, all zones and illuminance as well.

Not that I have seen.