Sonoff Siren Driver?

Sonoff has a new Zigbee Siren that is USB powered but has battery backup.

However, I can't figure out which driver to use for this, and my foray into asking AI to provide one has not worked well.

Is there a way to find out if Hubitat will provide a driver for this?
Alternatively, does anyone have a suggestion of how to write the promt that will get AI (which one?) to write a driver for this device?

I had Claude write a driver for me that wasn't responding correctly to the Generic Zigbee Lock (No Keypad). Apparently, the Danalocks return different information than expected. I was having issues with complications on an Apple watch not updating the symbols for locks, but OK for other devices. Lock driver not working. Claude discovered the issue (with a lot of questioning and prodding) and delivered a fully working driver on the first try.
In other words, it should be fairly straight forward. Something like:
Create a driver for Hubitat using groovy. The device is Link to product page

Tried Claude, but looks like you need to sign up for an account to use it. Although it's free, I loathe one more sign up, so thought I would give Chat GPT another try with @Slate 's above prompt, specifically:

"Create a driver for Hubitat using groovy. The device is Amazon.com: SONOFF Indoor Zigbee Siren SNZB-09P | 105 dB audible and visual alarm, linked sensor, dual power supply modes, compatible with Home Assistant : Electronics "

After some back and forth and me giving it the Device Data from the Device info page, specifically telling it I want to know when it is getting USB power and when it is operating on battery, and telling it more than once not to assume anything or make things up (I swear if a PM who worked for me made up as much as Chat GPT did they would be on a correction plan), I got what MIGHT be a working driver. As I have no idea how to do Github or anything like that, I'm sharing it below in case it helps someone. I've got no idea how to fix the wonky formatting when I paste the driver in here so sorry about that...

/*

  • SONOFF SNZB-09P
  • Hubitat Elevation
  • Firmware observed: 1.1.0
  • Verified device fingerprint:
  • Manufacturer: SONOFF
  • Model: SNZB-09P
  • Application: 11
  • Endpoint: 01
  • In Clusters: 0000,0001,0003,0020,0500,0502,FC11
  • Out Clusters: 0003,0019
  • Verified from device refresh:
  • 0001/0021 = battery percentage
  • FC11/0024 = power supply mode
  •   00 = battery
    
  •   01 = mains
    
  • FC11/2000 = tamper
  •   00 = clear
    
  •   01 = detected
    
  • FC11/2022 = light enabled
  • FC11/2023 = sound type
  • FC11/2024 = volume
  • FC11/2025 = duration
  • FC11/2026 = sound enabled
  • Manufacturer code used by the existing working driver:
  • 0x1286
    */

metadata {
definition(
name: "SONOFF SNZB-09P ChatGPT",
namespace: "custom",
author: "ChatGPT"
) {
capability "Actuator"
capability "Alarm"
capability "Battery"
capability "PowerSource"
capability "Refresh"
capability "Switch"
capability "TamperAlert"

    command "siren"
    command "stop"
    command "test"
    command "strobe"
    command "soundOnly"
    command "lightOnly"

    command "setSoundType", [
        [name: "Sound Type", type: "ENUM", constraints: [
            "siren_classic",
            "siren_steady",
            "siren_rising",
            "siren_warning",
            "siren_rapid",
            "siren_emergency",
            "tone_chirp",
            "tone_hi_lo",
            "tone_intermittent",
            "tone_pulse"
        ]]
    ]

    command "setVolume", [
        [name: "Volume", type: "ENUM", constraints: [
            "low",
            "medium",
            "high",
            "max"
        ]]
    ]

    command "setDuration", [
        [name: "Duration seconds", type: "NUMBER"]
    ]

    attribute "soundType", "string"
    attribute "volume", "string"
    attribute "duration", "number"
    attribute "soundEnabled", "string"
    attribute "lightEnabled", "string"
}

fingerprint profileId: "0104",
    endpointId: "01",
    deviceId: "0011",
    inClusters: "0000,0001,0003,0020,0500,0502,FC11",
    outClusters: "0003,0019",
    manufacturer: "SONOFF",
    model: "SNZB-09P"

}

preferences {
input(
name: "logEnable",
type: "bool",
title: "Enable debug logging",
defaultValue: true
)
}

/*

  • ==========================================================================
  • LIFECYCLE
  • ==========================================================================
    */

def installed() {
log.info "${device.displayName}: installed"
runIn(2, refresh)
}

def updated() {
log.info "${device.displayName}: updated"
runIn(2, refresh)
}

/*

  • ==========================================================================
  • SIREN
  • ==========================================================================
    */

def on() {
siren()
}

def off() {
stop()
}

def siren() {

if (logEnable) {
    log.debug "${device.displayName}: siren ON"
}

sendEvent(name: "switch", value: "on")
sendEvent(name: "alarm", value: "siren")

return sendAlertCommand("00")

}

def stop() {

if (logEnable) {
    log.debug "${device.displayName}: siren OFF"
}

sendEvent(name: "switch", value: "off")
sendEvent(name: "alarm", value: "off")

return sendAlertCommand("01")

}

def test() {

List<String> cmds = sendAlertCommand("00")

sendEvent(name: "switch", value: "on")
sendEvent(name: "alarm", value: "siren")

runIn(3, "stop")

return cmds

}

def strobe() {

List<String> cmds = []

cmds += writeBoolean(0x2026, true)
cmds += writeBoolean(0x2022, true)
cmds += sendAlertCommand("00")

sendEvent(name: "switch", value: "on")
sendEvent(name: "alarm", value: "both")

return cmds

}

def soundOnly() {

List<String> cmds = []

cmds += writeBoolean(0x2026, true)
cmds += writeBoolean(0x2022, false)
cmds += sendAlertCommand("00")

sendEvent(name: "switch", value: "on")
sendEvent(name: "alarm", value: "siren")

return cmds

}

def lightOnly() {

List<String> cmds = []

cmds += writeBoolean(0x2026, false)
cmds += writeBoolean(0x2022, true)
cmds += sendAlertCommand("00")

sendEvent(name: "switch", value: "on")
sendEvent(name: "alarm", value: "strobe")

return cmds

}

/*

  • ==========================================================================
  • SOUND TYPE
  • ==========================================================================
    */

def setSoundType(String type) {

Map values = [
    siren_classic     : 0x00,
    siren_steady      : 0x01,
    siren_rising      : 0x02,
    siren_warning     : 0x03,
    siren_rapid       : 0x04,
    siren_emergency   : 0x05,
    tone_chirp        : 0x06,
    tone_hi_lo        : 0x07,
    tone_intermittent : 0x08,
    tone_pulse        : 0x09
]

if (!values.containsKey(type)) {
    log.warn "${device.displayName}: invalid sound type: ${type}"
    return []
}

sendEvent(name: "soundType", value: type)

return zigbee.writeAttribute(
    0xFC11,
    0x2023,
    0x30,
    values[type],
    [mfgCode: "0x1286"],
    200
)

}

/*

  • ==========================================================================
  • VOLUME
  • ==========================================================================
    */

def setVolume(String volume) {

Map values = [
    low    : 0x00,
    medium : 0x01,
    high   : 0x02,
    max    : 0x03
]

if (!values.containsKey(volume)) {
    log.warn "${device.displayName}: invalid volume: ${volume}"
    return []
}

sendEvent(name: "volume", value: volume)

return zigbee.writeAttribute(
    0xFC11,
    0x2024,
    0x30,
    values[volume],
    [mfgCode: "0x1286"],
    200
)

}

/*

  • ==========================================================================
  • DURATION
  • ==========================================================================
    */

def setDuration(def seconds) {

Integer duration

try {
    duration = seconds as Integer
}
catch (Exception e) {
    log.warn "${device.displayName}: invalid duration: ${seconds}"
    return []
}

duration = Math.max(1, Math.min(900, duration))

sendEvent(
    name: "duration",
    value: duration,
    unit: "s"
)

return zigbee.writeAttribute(
    0xFC11,
    0x2025,
    0x21,
    duration,
    [mfgCode: "0x1286"],
    200
)

}

/*

  • ==========================================================================
  • REFRESH
  • ==========================================================================
    */

def refresh() {

if (logEnable) {
    log.debug "${device.displayName}: refresh"
}

List<String> cmds = []

/*
 * Battery Percentage Remaining
 *
 * Verified:
 *   raw C8 (200) = 100%
 */
cmds += zigbee.readAttribute(
    0x0001,
    0x0021,
    [:],
    200
)

/*
 * SONOFF power supply mode
 *
 * Verified on this device:
 *   00 = battery
 *   01 = mains
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x0024,
    [mfgCode: "0x1286"],
    200
)

/*
 * Tamper
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2000,
    [mfgCode: "0x1286"],
    200
)

/*
 * Alarm light enabled
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2022,
    [mfgCode: "0x1286"],
    200
)

/*
 * Alarm sound type
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2023,
    [mfgCode: "0x1286"],
    200
)

/*
 * Alarm volume
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2024,
    [mfgCode: "0x1286"],
    200
)

/*
 * Alarm duration
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2025,
    [mfgCode: "0x1286"],
    200
)

/*
 * Alarm sound enabled
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2026,
    [mfgCode: "0x1286"],
    200
)

return cmds

}

/*

  • ==========================================================================
  • PARSE
  • ==========================================================================
    */

def parse(String description) {

if (!description) {
    return
}

if (logEnable) {
    log.debug "${device.displayName}: ${description}"
}

Map msg

try {
    msg = zigbee.parseDescriptionAsMap(description)
}
catch (Exception e) {
    log.warn "${device.displayName}: unable to parse: ${e}"
    return
}

if (!msg) {
    return
}

Integer cluster = getCluster(msg)

if (cluster == null) {
    return
}

switch (cluster) {

    case 0x0001:
        parseBattery(msg)
        break

    case 0xFC11:
        parseFC11(msg)
        break

    case 0x0500:
        parseIAS(msg)
        break
}

}

/*

  • ==========================================================================
  • BATTERY
  • ==========================================================================
    */

private void parseBattery(Map msg) {

Integer attr = getAttribute(msg)

if (attr != 0x0021) {
    return
}

Integer raw = getValue(msg)

if (raw == null) {
    return
}

Integer percent = Math.round(raw / 2.0) as Integer

percent = Math.max(0, Math.min(100, percent))

sendEvent(
    name: "battery",
    value: percent,
    unit: "%"
)

if (logEnable) {
    log.debug "${device.displayName}: battery=${percent}% raw=${raw}"
}

}

/*

  • ==========================================================================
  • SONOFF FC11
  • ==========================================================================
    */

private void parseFC11(Map msg) {

Integer attr = getAttribute(msg)
Integer value = getValue(msg)

if (logEnable) {
    log.debug "${device.displayName}: FC11 attr=${hex4(attr)} value=${value} data=${msg.data}"
}

if (attr == null || value == null) {
    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 0024
 *
 * VERIFIED FROM USER'S DEVICE:
 *
 *   raw 01 -> mains while connected to USB
 *   raw 00 -> battery
 * ----------------------------------------------------------------------
 */

if (attr == 0x0024) {

    String source

    switch (value) {

        case 0x00:
            source = "battery"
            break

        case 0x01:
            source = "mains"
            break

        default:
            source = "unknown"
            break
    }

    if (logEnable) {
        log.debug "${device.displayName}: powerSupplyMode raw=${value} -> ${source}"
    }

    sendEvent(
        name: "powerSource",
        value: source
    )

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2000
 *
 * Verified from user's refresh:
 *   raw 00 -> clear
 * ----------------------------------------------------------------------
 */

if (attr == 0x2000) {

    String tamper = value == 0x01 ? "detected" : "clear"

    sendEvent(
        name: "tamper",
        value: tamper
    )

    if (logEnable) {
        log.debug "${device.displayName}: tamper=${tamper}"
    }

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2022
 *
 * Alarm light enable
 * ----------------------------------------------------------------------
 */

if (attr == 0x2022) {

    String enabled = value == 0x01 ? "on" : "off"

    sendEvent(
        name: "lightEnabled",
        value: enabled
    )

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2023
 *
 * Sound type
 * ----------------------------------------------------------------------
 */

if (attr == 0x2023) {

    Map names = [
        0x00: "siren_classic",
        0x01: "siren_steady",
        0x02: "siren_rising",
        0x03: "siren_warning",
        0x04: "siren_rapid",
        0x05: "siren_emergency",
        0x06: "tone_chirp",
        0x07: "tone_hi_lo",
        0x08: "tone_intermittent",
        0x09: "tone_pulse"
    ]

    String soundType = names[value]

    if (soundType != null) {
        sendEvent(
            name: "soundType",
            value: soundType
        )
    }

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2024
 *
 * Verified from user's refresh:
 *   raw 00 -> value 0
 * ----------------------------------------------------------------------
 */

if (attr == 0x2024) {

    Map names = [
        0x00: "low",
        0x01: "medium",
        0x02: "high",
        0x03: "max"
    ]

    String volume = names[value]

    if (volume != null) {
        sendEvent(
            name: "volume",
            value: volume
        )
    }

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2025
 *
 * Verified from user's refresh:
 *   raw 0A00 -> 10
 * ----------------------------------------------------------------------
 */

if (attr == 0x2025) {

    sendEvent(
        name: "duration",
        value: value,
        unit: "s"
    )

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2026
 *
 * Verified from user's refresh:
 *   raw 00 -> disabled
 * ----------------------------------------------------------------------
 */

if (attr == 0x2026) {

    String enabled = value == 0x01 ? "on" : "off"

    sendEvent(
        name: "soundEnabled",
        value: enabled
    )

    return
}

}

/*

  • ==========================================================================
  • IAS ZONE
  • ==========================================================================
    */

private void parseIAS(Map msg) {

Integer attr = getAttribute(msg)

if (attr != 0x0002) {
    return
}

Integer status = getValue(msg)

if (status == null) {
    return
}

/*
 * IAS ZoneStatus tamper bit.
 */
boolean tamper = (status & 0x04) != 0

sendEvent(
    name: "tamper",
    value: tamper ? "detected" : "clear"
)

if (logEnable) {
    log.debug "${device.displayName}: IAS tamper=${tamper}"
}

}

/*

  • ==========================================================================
  • ZIGBEE COMMAND HELPERS
  • ==========================================================================
    */

private List sendAlertCommand(String value) {

return zigbee.command(
    0xFC11,
    0x0F,
    [mfgCode: "0x1286"],
    200,
    value
)

}

private List writeBoolean(
Integer attribute,
boolean value
) {

return zigbee.writeAttribute(
    0xFC11,
    attribute,
    0x10,
    value ? 0x01 : 0x00,
    [mfgCode: "0x1286"],
    200
)

}

/*

  • ==========================================================================
  • PARSING HELPERS
  • ==========================================================================
    */

private Integer getCluster(Map msg) {

if (msg.clusterInt != null) {
    return msg.clusterInt as Integer
}

if (msg.cluster != null) {
    return parseHex(msg.cluster)
}

return null

}

private Integer getAttribute(Map msg) {

if (msg.attrInt != null) {
    return msg.attrInt as Integer
}

if (msg.attrId != null) {
    return parseHex(msg.attrId)
}

return null

}

private Integer getValue(Map msg) {

if (msg.value != null) {
    return parseZigbeeValue(msg.value)
}

if (msg.data != null) {
    return parseZigbeeValue(msg.data)
}

return null

}

private Integer parseZigbeeValue(def value) {

if (value == null) {
    return null
}

if (value instanceof Number) {
    return value as Integer
}

if (value instanceof List) {

    if (value.size() == 1) {
        return parseZigbeeValue(value[0])
    }

    if (value.size() == 2) {

        Integer low = parseZigbeeValue(value[0])
        Integer high = parseZigbeeValue(value[1])

        if (low != null && high != null) {
            return low | (high << 8)
        }
    }

    return null
}

String text = value.toString()
    .replace("[", "")
    .replace("]", "")
    .trim()

try {

    if (text.startsWith("0x") || text.startsWith("0X")) {
        return Integer.parseInt(text.substring(2), 16)
    }

    return Integer.parseInt(text, 16)
}
catch (Exception e) {

    try {
        return text as Integer
    }
    catch (Exception ignored) {
        return null
    }
}

}

private Integer parseHex(def value) {

if (value == null) {
    return null
}

String text = value.toString()
    .replace("0x", "")
    .replace("0X", "")
    .trim()

try {
    return Integer.parseInt(text, 16)
}
catch (Exception e) {
    return null
}

}

private String hex4(Integer value) {

if (value == null) {
    return "null"
}

return String.format("%04X", value)

}

Hi @calinatl ,

Congrats on your first published AI-assisted driver ! :partying_face:

The best way to publish it in the forum is to use the Preformatted text tool in the editor :

Click on the tool :

image

then, paste the code :

/*

SONOFF SNZB-09P
Hubitat Elevation
Firmware observed: 1.1.0
Verified device fingerprint:
Manufacturer: SONOFF
Model: SNZB-09P
Application: 11
Endpoint: 01
In Clusters: 0000,0001,0003,0020,0500,0502,FC11
Out Clusters: 0003,0019
Verified from device refresh:
0001/0021 = battery percentage
FC11/0024 = power supply mode
  00 = battery
  01 = mains
FC11/2000 = tamper
  00 = clear
  01 = detected
FC11/2022 = light enabled
FC11/2023 = sound type
FC11/2024 = volume
FC11/2025 = duration
FC11/2026 = sound enabled
Manufacturer code used by the existing working driver:
0x1286
*/
metadata {
definition(
name: "SONOFF SNZB-09P ChatGPT",
namespace: "custom",
author: "ChatGPT"
) {
capability "Actuator"
capability "Alarm"
capability "Battery"
capability "PowerSource"
capability "Refresh"
capability "Switch"
capability "TamperAlert"

    command "siren"
    command "stop"
    command "test"
    command "strobe"
    command "soundOnly"
    command "lightOnly"

    command "setSoundType", [
        [name: "Sound Type", type: "ENUM", constraints: [
            "siren_classic",
            "siren_steady",
            "siren_rising",
            "siren_warning",
            "siren_rapid",
            "siren_emergency",
            "tone_chirp",
            "tone_hi_lo",
            "tone_intermittent",
            "tone_pulse"
        ]]
    ]

    command "setVolume", [
        [name: "Volume", type: "ENUM", constraints: [
            "low",
            "medium",
            "high",
            "max"
        ]]
    ]

    command "setDuration", [
        [name: "Duration seconds", type: "NUMBER"]
    ]

    attribute "soundType", "string"
    attribute "volume", "string"
    attribute "duration", "number"
    attribute "soundEnabled", "string"
    attribute "lightEnabled", "string"
}

fingerprint profileId: "0104",
    endpointId: "01",
    deviceId: "0011",
    inClusters: "0000,0001,0003,0020,0500,0502,FC11",
    outClusters: "0003,0019",
    manufacturer: "SONOFF",
    model: "SNZB-09P"
}

preferences {
input(
name: "logEnable",
type: "bool",
title: "Enable debug logging",
defaultValue: true
)
}

/*

==========================================================================
LIFECYCLE
==========================================================================
*/
def installed() {
log.info "${device.displayName}: installed"
runIn(2, refresh)
}

def updated() {
log.info "${device.displayName}: updated"
runIn(2, refresh)
}

/*

==========================================================================
SIREN
==========================================================================
*/
def on() {
siren()
}

def off() {
stop()
}

def siren() {

if (logEnable) {
    log.debug "${device.displayName}: siren ON"
}

sendEvent(name: "switch", value: "on")
sendEvent(name: "alarm", value: "siren")

return sendAlertCommand("00")
}

def stop() {

if (logEnable) {
    log.debug "${device.displayName}: siren OFF"
}

sendEvent(name: "switch", value: "off")
sendEvent(name: "alarm", value: "off")

return sendAlertCommand("01")
}

def test() {

List<String> cmds = sendAlertCommand("00")

sendEvent(name: "switch", value: "on")
sendEvent(name: "alarm", value: "siren")

runIn(3, "stop")

return cmds
}

def strobe() {

List<String> cmds = []

cmds += writeBoolean(0x2026, true)
cmds += writeBoolean(0x2022, true)
cmds += sendAlertCommand("00")

sendEvent(name: "switch", value: "on")
sendEvent(name: "alarm", value: "both")

return cmds
}

def soundOnly() {

List<String> cmds = []

cmds += writeBoolean(0x2026, true)
cmds += writeBoolean(0x2022, false)
cmds += sendAlertCommand("00")

sendEvent(name: "switch", value: "on")
sendEvent(name: "alarm", value: "siren")

return cmds
}

def lightOnly() {

List<String> cmds = []

cmds += writeBoolean(0x2026, false)
cmds += writeBoolean(0x2022, true)
cmds += sendAlertCommand("00")

sendEvent(name: "switch", value: "on")
sendEvent(name: "alarm", value: "strobe")

return cmds
}

/*

==========================================================================
SOUND TYPE
==========================================================================
*/
def setSoundType(String type) {

Map values = [
    siren_classic     : 0x00,
    siren_steady      : 0x01,
    siren_rising      : 0x02,
    siren_warning     : 0x03,
    siren_rapid       : 0x04,
    siren_emergency   : 0x05,
    tone_chirp        : 0x06,
    tone_hi_lo        : 0x07,
    tone_intermittent : 0x08,
    tone_pulse        : 0x09
]

if (!values.containsKey(type)) {
    log.warn "${device.displayName}: invalid sound type: ${type}"
    return []
}

sendEvent(name: "soundType", value: type)

return zigbee.writeAttribute(
    0xFC11,
    0x2023,
    0x30,
    values[type],
    [mfgCode: "0x1286"],
    200
)
}

/*

==========================================================================
VOLUME
==========================================================================
*/
def setVolume(String volume) {

Map values = [
    low    : 0x00,
    medium : 0x01,
    high   : 0x02,
    max    : 0x03
]

if (!values.containsKey(volume)) {
    log.warn "${device.displayName}: invalid volume: ${volume}"
    return []
}

sendEvent(name: "volume", value: volume)

return zigbee.writeAttribute(
    0xFC11,
    0x2024,
    0x30,
    values[volume],
    [mfgCode: "0x1286"],
    200
)
}

/*

==========================================================================
DURATION
==========================================================================
*/
def setDuration(def seconds) {

Integer duration

try {
    duration = seconds as Integer
}
catch (Exception e) {
    log.warn "${device.displayName}: invalid duration: ${seconds}"
    return []
}

duration = Math.max(1, Math.min(900, duration))

sendEvent(
    name: "duration",
    value: duration,
    unit: "s"
)

return zigbee.writeAttribute(
    0xFC11,
    0x2025,
    0x21,
    duration,
    [mfgCode: "0x1286"],
    200
)
}

/*

==========================================================================
REFRESH
==========================================================================
*/
def refresh() {

if (logEnable) {
    log.debug "${device.displayName}: refresh"
}

List<String> cmds = []

/*
 * Battery Percentage Remaining
 *
 * Verified:
 *   raw C8 (200) = 100%
 */
cmds += zigbee.readAttribute(
    0x0001,
    0x0021,
    [:],
    200
)

/*
 * SONOFF power supply mode
 *
 * Verified on this device:
 *   00 = battery
 *   01 = mains
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x0024,
    [mfgCode: "0x1286"],
    200
)

/*
 * Tamper
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2000,
    [mfgCode: "0x1286"],
    200
)

/*
 * Alarm light enabled
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2022,
    [mfgCode: "0x1286"],
    200
)

/*
 * Alarm sound type
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2023,
    [mfgCode: "0x1286"],
    200
)

/*
 * Alarm volume
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2024,
    [mfgCode: "0x1286"],
    200
)

/*
 * Alarm duration
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2025,
    [mfgCode: "0x1286"],
    200
)

/*
 * Alarm sound enabled
 */
cmds += zigbee.readAttribute(
    0xFC11,
    0x2026,
    [mfgCode: "0x1286"],
    200
)

return cmds
}

/*

==========================================================================
PARSE
==========================================================================
*/
def parse(String description) {

if (!description) {
    return
}

if (logEnable) {
    log.debug "${device.displayName}: ${description}"
}

Map msg

try {
    msg = zigbee.parseDescriptionAsMap(description)
}
catch (Exception e) {
    log.warn "${device.displayName}: unable to parse: ${e}"
    return
}

if (!msg) {
    return
}

Integer cluster = getCluster(msg)

if (cluster == null) {
    return
}

switch (cluster) {

    case 0x0001:
        parseBattery(msg)
        break

    case 0xFC11:
        parseFC11(msg)
        break

    case 0x0500:
        parseIAS(msg)
        break
}
}

/*

==========================================================================
BATTERY
==========================================================================
*/
private void parseBattery(Map msg) {

Integer attr = getAttribute(msg)

if (attr != 0x0021) {
    return
}

Integer raw = getValue(msg)

if (raw == null) {
    return
}

Integer percent = Math.round(raw / 2.0) as Integer

percent = Math.max(0, Math.min(100, percent))

sendEvent(
    name: "battery",
    value: percent,
    unit: "%"
)

if (logEnable) {
    log.debug "${device.displayName}: battery=${percent}% raw=${raw}"
}
}

/*

==========================================================================
SONOFF FC11
==========================================================================
*/
private void parseFC11(Map msg) {

Integer attr = getAttribute(msg)
Integer value = getValue(msg)

if (logEnable) {
    log.debug "${device.displayName}: FC11 attr=${hex4(attr)} value=${value} data=${msg.data}"
}

if (attr == null || value == null) {
    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 0024
 *
 * VERIFIED FROM USER'S DEVICE:
 *
 *   raw 01 -> mains while connected to USB
 *   raw 00 -> battery
 * ----------------------------------------------------------------------
 */

if (attr == 0x0024) {

    String source

    switch (value) {

        case 0x00:
            source = "battery"
            break

        case 0x01:
            source = "mains"
            break

        default:
            source = "unknown"
            break
    }

    if (logEnable) {
        log.debug "${device.displayName}: powerSupplyMode raw=${value} -> ${source}"
    }

    sendEvent(
        name: "powerSource",
        value: source
    )

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2000
 *
 * Verified from user's refresh:
 *   raw 00 -> clear
 * ----------------------------------------------------------------------
 */

if (attr == 0x2000) {

    String tamper = value == 0x01 ? "detected" : "clear"

    sendEvent(
        name: "tamper",
        value: tamper
    )

    if (logEnable) {
        log.debug "${device.displayName}: tamper=${tamper}"
    }

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2022
 *
 * Alarm light enable
 * ----------------------------------------------------------------------
 */

if (attr == 0x2022) {

    String enabled = value == 0x01 ? "on" : "off"

    sendEvent(
        name: "lightEnabled",
        value: enabled
    )

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2023
 *
 * Sound type
 * ----------------------------------------------------------------------
 */

if (attr == 0x2023) {

    Map names = [
        0x00: "siren_classic",
        0x01: "siren_steady",
        0x02: "siren_rising",
        0x03: "siren_warning",
        0x04: "siren_rapid",
        0x05: "siren_emergency",
        0x06: "tone_chirp",
        0x07: "tone_hi_lo",
        0x08: "tone_intermittent",
        0x09: "tone_pulse"
    ]

    String soundType = names[value]

    if (soundType != null) {
        sendEvent(
            name: "soundType",
            value: soundType
        )
    }

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2024
 *
 * Verified from user's refresh:
 *   raw 00 -> value 0
 * ----------------------------------------------------------------------
 */

if (attr == 0x2024) {

    Map names = [
        0x00: "low",
        0x01: "medium",
        0x02: "high",
        0x03: "max"
    ]

    String volume = names[value]

    if (volume != null) {
        sendEvent(
            name: "volume",
            value: volume
        )
    }

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2025
 *
 * Verified from user's refresh:
 *   raw 0A00 -> 10
 * ----------------------------------------------------------------------
 */

if (attr == 0x2025) {

    sendEvent(
        name: "duration",
        value: value,
        unit: "s"
    )

    return
}


/*
 * ----------------------------------------------------------------------
 * FC11 / 2026
 *
 * Verified from user's refresh:
 *   raw 00 -> disabled
 * ----------------------------------------------------------------------
 */

if (attr == 0x2026) {

    String enabled = value == 0x01 ? "on" : "off"

    sendEvent(
        name: "soundEnabled",
        value: enabled
    )

    return
}
}

/*

==========================================================================
IAS ZONE
==========================================================================
*/
private void parseIAS(Map msg) {

Integer attr = getAttribute(msg)

if (attr != 0x0002) {
    return
}

Integer status = getValue(msg)

if (status == null) {
    return
}

/*
 * IAS ZoneStatus tamper bit.
 */
boolean tamper = (status & 0x04) != 0

sendEvent(
    name: "tamper",
    value: tamper ? "detected" : "clear"
)

if (logEnable) {
    log.debug "${device.displayName}: IAS tamper=${tamper}"
}
}

/*

==========================================================================
ZIGBEE COMMAND HELPERS
==========================================================================
*/
private List sendAlertCommand(String value) {

return zigbee.command(
    0xFC11,
    0x0F,
    [mfgCode: "0x1286"],
    200,
    value
)
}

private List writeBoolean(
Integer attribute,
boolean value
) {

return zigbee.writeAttribute(
    0xFC11,
    attribute,
    0x10,
    value ? 0x01 : 0x00,
    [mfgCode: "0x1286"],
    200
)
}

/*

==========================================================================
PARSING HELPERS
==========================================================================
*/
private Integer getCluster(Map msg) {

if (msg.clusterInt != null) {
    return msg.clusterInt as Integer
}

if (msg.cluster != null) {
    return parseHex(msg.cluster)
}

return null
}

private Integer getAttribute(Map msg) {

if (msg.attrInt != null) {
    return msg.attrInt as Integer
}

if (msg.attrId != null) {
    return parseHex(msg.attrId)
}

return null
}

private Integer getValue(Map msg) {

if (msg.value != null) {
    return parseZigbeeValue(msg.value)
}

if (msg.data != null) {
    return parseZigbeeValue(msg.data)
}

return null
}

private Integer parseZigbeeValue(def value) {

if (value == null) {
    return null
}

if (value instanceof Number) {
    return value as Integer
}

if (value instanceof List) {

    if (value.size() == 1) {
        return parseZigbeeValue(value[0])
    }

    if (value.size() == 2) {

        Integer low = parseZigbeeValue(value[0])
        Integer high = parseZigbeeValue(value[1])

        if (low != null && high != null) {
            return low | (high << 8)
        }
    }

    return null
}

String text = value.toString()
    .replace("[", "")
    .replace("]", "")
    .trim()

try {

    if (text.startsWith("0x") || text.startsWith("0X")) {
        return Integer.parseInt(text.substring(2), 16)
    }

    return Integer.parseInt(text, 16)
}
catch (Exception e) {

    try {
        return text as Integer
    }
    catch (Exception ignored) {
        return null
    }
}
}

private Integer parseHex(def value) {

if (value == null) {
    return null
}

String text = value.toString()
    .replace("0x", "")
    .replace("0X", "")
    .trim()

try {
    return Integer.parseInt(text, 16)
}
catch (Exception e) {
    return null
}
}

private String hex4(Integer value) {

if (value == null) {
    return "null"
}

return String.format("%04X", value)
}

You can try editing your previous post and put the code inside the Preformatted text section.


Update :

I passed the ChatGPT code to Claude. Seeing code from a competitor AI, Claude thought very hard to find as many bugs as possible! :slight_smile:

It found plenty of real ones - a missing both() command, Zigbee commands quietly thrown away by runIn(), strobe() and "light only" doing each other's job, no IAS enrollment, no bindings at all.

Then I asked it to double-check against the Zigbee2MQTT converter before I flashed anything.

Turns out three of its own "corrections" were wrong, and one of them ChatGPT had gotten right all along: the siren command payload is 0x00 = ON and 0x01 = OFF. Yes, inverted. Claude had confidently "fixed" that into a bug... :frowning:

It also learned that the sound-type values aren't sequential, and that the manufacturer code 0x1286 is only sent on 3 of the 7 attributes — not on the command.


Moral of the story: two AIs, four wrong assumptions, one datasheet. Always check the source!

This is the Claude 'corrected' code , version 2.0.0 :

/**
 *  SONOFF SNZB-09P Zigbee Siren - Hubitat Elevation driver
 *
 *  Protocol verified against Koenkk/zigbee-herdsman-converters,
 *  src/devices/sonoff.ts (SNZB-09P definition, fzLocal/tzLocal.snzb_09p_alert,
 *  sonoffExtend.powerSupplyModeWithChangeBatteryState / batteryWithPowerSupplyMode).
 *
 *  ---------------------------------------------------------------------------
 *  CLUSTER 0xFC11 (customClusterEwelink) - the cluster itself is NOT declared
 *  manufacturer-specific. The manufacturer code 0x1286 (Shenzhen CoolKit) is
 *  applied per access, and only to some attributes. This asymmetry is real and
 *  is reproduced exactly here:
 *
 *      attr    name                type      mfg code 0x1286?
 *      0x0024  powerSupplyMode     ENUM8     no       0=battery 1=external
 *      0x2000  spilt (tamper)      UINT8     YES      0=clear   1=detected
 *      0x2022  alarmLightEnable    BOOLEAN   YES
 *      0x2023  alarmSoundType      ENUM8     no
 *      0x2024  alarmVolumeLevel    ENUM8     no       0=low..3=max
 *      0x2025  alarmDuration       UINT16    no       1..900 s
 *      0x2026  alarmSoundEnable    BOOLEAN   YES
 *
 *      cmd 0x0F alertCommand - NO mfg code, default response disabled.
 *          outbound payload:  00 = START the siren, 01 = STOP it
 *                             (yes, inverted from what you would expect)
 *          inbound  payload:  04 <alarmType>, alarmType 0=none(off)
 *                             1=manual(on) 2=scene(on)
 *
 *  The device sends the inbound alertCommand unsolicited, so siren state is
 *  tracked from the device rather than guessed - including alarms started by a
 *  scene or by the button on the unit.
 *
 *  Sound type values are NOT sequential - this ordering is from the converter:
 *      0x00 siren_classic   0x05 siren_warning
 *      0x01 siren_steady    0x06 siren_rapid
 *      0x02 tone_chirp      0x07 tone_intermittent
 *      0x03 siren_rising    0x08 siren_emergency
 *      0x04 tone_hi_lo      0x09 tone_pulse
 *
 *  Battery quirks copied from the reference implementation:
 *      raw 0xFF (255) means "unknown" and is discarded.
 *      On external power the device only reports a meaningful 100%; any other
 *      value while powerSource is mains is discarded.
 *
 *  NOT done, deliberately: the device advertises IAS Zone (0x0500) and IAS
 *  Warning Device (0x0502), but the reference implementation ignores both -
 *  tamper arrives on 0xFC11/0x2000 and the siren is driven by the private
 *  command. No IAS enrollment is performed here either.
 *  ---------------------------------------------------------------------------
 *
 *  Capabilities: Alarm (siren/strobe/both/off), Chime (playSound/stop),
 *  AudioVolume (setVolume 0-100, mute/unmute), Tone (beep), Switch, Battery,
 *  PowerSource, TamperAlert, Refresh, Configuration.
 */

import groovy.transform.Field

@Field static final String DRIVER_VERSION = '2.0.0'

@Field static final int CLUSTER_BASIC  = 0x0000
@Field static final int CLUSTER_POWER  = 0x0001
@Field static final int CLUSTER_SONOFF = 0xFC11

@Field static final String MFG_CODE = '0x1286'

@Field static final int ATTR_BATTERY_PCT  = 0x0021
@Field static final int ATTR_BATTERY_VOLT = 0x0020

@Field static final int ATTR_POWER_MODE = 0x0024   // no mfg code
@Field static final int ATTR_TAMPER     = 0x2000   // mfg code
@Field static final int ATTR_LIGHT_EN   = 0x2022   // mfg code
@Field static final int ATTR_SOUND_TYPE = 0x2023   // no mfg code
@Field static final int ATTR_VOLUME     = 0x2024   // no mfg code
@Field static final int ATTR_DURATION   = 0x2025   // no mfg code
@Field static final int ATTR_SOUND_EN   = 0x2026   // mfg code

@Field static final int CMD_ALERT = 0x0F

@Field static final String ALERT_START = '00'   // verified: 00 starts the siren
@Field static final String ALERT_STOP  = '01'   // verified: 01 stops it

// ZCL data types
@Field static final int DT_BOOLEAN = 0x10
@Field static final int DT_ENUM8   = 0x30
@Field static final int DT_UINT8   = 0x20
@Field static final int DT_UINT16  = 0x21

@Field static final int DELAY_MS = 200

// Ordered by the device's own attribute value, so Chime sound number = value+1.
@Field static final List SOUND_NAMES = [
    'siren_classic',      // 0x00
    'siren_steady',       // 0x01
    'tone_chirp',         // 0x02
    'siren_rising',       // 0x03
    'tone_hi_lo',         // 0x04
    'siren_warning',      // 0x05
    'siren_rapid',        // 0x06
    'tone_intermittent',  // 0x07
    'siren_emergency',    // 0x08
    'tone_pulse'          // 0x09
]

@Field static final List VOLUME_NAMES = ['low', 'medium', 'high', 'max']

@Field static final Map ALARM_TYPES = [0x00: 'none', 0x01: 'manual', 0x02: 'scene']

metadata {
    definition(name: 'SONOFF SNZB-09P Siren', namespace: 'custom', author: 'community') {
        capability 'Actuator'
        capability 'Alarm'
        capability 'AudioVolume'
        capability 'Battery'
        capability 'Chime'
        capability 'Configuration'
        capability 'PowerSource'
        capability 'Refresh'
        capability 'Switch'
        capability 'TamperAlert'
        capability 'Tone'

        command 'setSoundType', [[name: 'Sound Type*', type: 'ENUM', constraints: SOUND_NAMES]]
        command 'setVolumeLevel', [[name: 'Volume Level*', type: 'ENUM', constraints: VOLUME_NAMES]]
        command 'setDuration', [[name: 'Duration seconds*', type: 'NUMBER', description: '1..900']]

        attribute 'duration',     'number'   // alarm duration, seconds
        attribute 'volumeLevel',  'string'   // named counterpart of AudioVolume's numeric volume
        attribute 'soundEnabled', 'string'
        attribute 'lightEnabled', 'string'
        attribute 'alarmType',    'string'   // none / manual / scene - how the alarm was started

        fingerprint profileId: '0104',
                    endpointId: '01',
                    inClusters: '0000,0001,0003,0020,0500,0502,FC11',
                    outClusters: '0003,0019',
                    manufacturer: 'SONOFF',
                    model: 'SNZB-09P'
    }

    preferences {
        input name: 'logEnable', type: 'bool', title: '<b>Enable debug logging</b>',
              description: 'Automatically turns off after 30 minutes.', defaultValue: true
        input name: 'txtEnable', type: 'bool', title: '<b>Enable description text logging</b>',
              defaultValue: true
    }
}

/* ==========================================================================
 * LIFECYCLE
 * ========================================================================== */

void installed() {
    log.info "${device.displayName}: installed (driver v${DRIVER_VERSION})"
    publishSoundEffects()
    runIn(2, 'configureHandler')
}

void updated() {
    log.info "${device.displayName}: preferences updated (driver v${DRIVER_VERSION})"
    unschedule('logsOff')
    if (settings?.logEnable) {
        log.info "${device.displayName}: debug logging will be disabled in 30 minutes"
        runIn(1800, 'logsOff')
    }
    publishSoundEffects()
    runIn(2, 'refreshHandler')
}

void logsOff() {
    log.info "${device.displayName}: debug logging disabled"
    device.updateSetting('logEnable', [value: 'false', type: 'bool'])
}

private void publishSoundEffects() {
    // Chime capability: soundEffects is a JSON map of sound number -> name.
    Map effects = [:]
    SOUND_NAMES.eachWithIndex { String soundName, int idx -> effects[(idx + 1) as String] = soundName }
    sendEvent(name: 'soundEffects', value: groovy.json.JsonOutput.toJson(effects))
}

/* ==========================================================================
 * CONFIGURE / REFRESH
 * ========================================================================== */

List<String> configure() {
    log.info "${device.displayName}: configure()"
    List<String> cmds = []
    String dni = device.deviceNetworkId
    String eui = device.zigbeeId

    // The reference implementation binds only 0xFC11. genPowerCfg is bound and
    // configured here as well so battery arrives without polling; if the device
    // rejects it you will see a ZCL failure in the log and nothing else breaks.
    cmds += "zdo bind 0x${dni} 0x01 0x01 0x${hex4(CLUSTER_SONOFF)} {${eui}} {}"
    cmds += "delay ${DELAY_MS}"
    cmds += "zdo bind 0x${dni} 0x01 0x01 0x${hex4(CLUSTER_POWER)} {${eui}} {}"
    cmds += "delay ${DELAY_MS}"
    cmds += zigbee.configureReporting(CLUSTER_POWER, ATTR_BATTERY_PCT, DT_UINT8, 3600, 43200, 0x02, [:], DELAY_MS)

    cmds += refresh()
    return cmds
}

List<String> refresh() {
    logDebug 'refresh()'
    List<String> cmds = []

    cmds += zigbee.readAttribute(CLUSTER_POWER, ATTR_BATTERY_PCT, [:], DELAY_MS)

    // Manufacturer-specific reads.
    [ATTR_TAMPER, ATTR_LIGHT_EN, ATTR_SOUND_EN].each { int attr ->
        cmds += zigbee.readAttribute(CLUSTER_SONOFF, attr, [mfgCode: MFG_CODE], DELAY_MS)
    }
    // Plain reads - the reference does NOT send the manufacturer code for these.
    [ATTR_POWER_MODE, ATTR_SOUND_TYPE, ATTR_VOLUME, ATTR_DURATION].each { int attr ->
        cmds += zigbee.readAttribute(CLUSTER_SONOFF, attr, [:], DELAY_MS)
    }
    return cmds
}

// runIn() discards whatever the target method returns, so anything scheduled
// has to transmit explicitly.
void configureHandler() { sendZigbeeCommands(configure()) }
void refreshHandler()   { sendZigbeeCommands(refresh()) }
void stopHandler()      { sendZigbeeCommands(stop()) }

private void sendZigbeeCommands(List<String> cmds) {
    if (!cmds) { return }
    logDebug "sending ${cmds.size()} zigbee commands"
    sendHubCommand(new hubitat.device.HubMultiAction(cmds, hubitat.device.Protocol.ZIGBEE))
}

/* ==========================================================================
 * ALARM / SWITCH / CHIME
 * ========================================================================== */

List<String> siren()  { return startAlarm(true,  false) }
List<String> strobe() { return startAlarm(false, true) }
List<String> both()   { return startAlarm(true,  true) }

List<String> on()  { return startAlarm(true, true) }
List<String> off() { return stop() }

/** Chime capability: play sound number 1..10 from soundEffects. */
List<String> playSound(soundnumber) {
    Integer number = toInteger(soundnumber)
    if (number == null || number < 1 || number > SOUND_NAMES.size()) {
        log.warn "${device.displayName}: playSound - number must be 1..${SOUND_NAMES.size()}, got '${soundnumber}'"
        return []
    }
    String soundName = SOUND_NAMES[number - 1]
    logInfo "playSound ${number} (${soundName})"

    List<String> cmds = writeSoundType(number - 1)
    cmds += startAlarm(true, false)
    return cmds
}

/** Tone capability: a short chirp, stopped after a second. */
List<String> beep() {
    logInfo 'beep'
    List<String> cmds = writeSoundType(SOUND_NAMES.indexOf('tone_chirp'))
    cmds += startAlarm(true, false)
    unschedule('alarmExpired')
    runIn(1, 'stopHandler')
    return cmds
}

/** Chime.stop() and Alarm.off() share this. */
List<String> stop() {
    logInfo 'alarm off'
    unschedule('alarmExpired')
    unschedule('stopHandler')
    // State is confirmed by the inbound alertCommand notification, but the
    // device is not guaranteed to send one, so update optimistically too.
    setAlarmState(false, null)
    return alertCommand(false)
}

private List<String> startAlarm(boolean sound, boolean light) {
    logInfo "alarm start (sound=${sound}, light=${light})"
    List<String> cmds = []

    // The private alert command carries no mode information, so the sounder and
    // light have to be enabled by attribute first. This does persist on the
    // device - it is how the vendor firmware works, not a shortcut.
    cmds += zigbee.writeAttribute(CLUSTER_SONOFF, ATTR_SOUND_EN, DT_BOOLEAN, sound ? 0x01 : 0x00, [mfgCode: MFG_CODE], DELAY_MS)
    cmds += zigbee.writeAttribute(CLUSTER_SONOFF, ATTR_LIGHT_EN, DT_BOOLEAN, light ? 0x01 : 0x00, [mfgCode: MFG_CODE], DELAY_MS)
    cmds += alertCommand(true)

    sendEvent(name: 'soundEnabled', value: sound ? 'on' : 'off')
    sendEvent(name: 'lightEnabled', value: light ? 'on' : 'off')
    setAlarmState(true, 'manual', sound, light)

    // Safety net: the device stops itself after alarmDuration. It normally
    // announces that with an alertCommand, but do not rely on it alone.
    unschedule('alarmExpired')
    runIn(safeDuration() + 3, 'alarmExpired')
    return cmds
}

void alarmExpired() {
    if (device.currentValue('alarm') != 'off') {
        logInfo 'alarm duration expired without a device notification'
        setAlarmState(false, 'none')
    }
}

/**
 * Single place where alarm / switch / status / alarmType are kept consistent.
 * sound and light default to the last known attribute values, which is what we
 * fall back on when the device tells us an alarm started that we did not start.
 */
private void setAlarmState(boolean active, String alarmType, Boolean sound = null, Boolean light = null) {
    String value = 'off'
    if (active) {
        boolean s = (sound != null) ? sound : (device.currentValue('soundEnabled') != 'off')
        boolean l = (light != null) ? light : (device.currentValue('lightEnabled') != 'off')
        value = (s && l) ? 'both' : (l ? 'strobe' : 'siren')
    }

    sendEvent(name: 'alarm',  value: value,
              descriptionText: "${device.displayName} alarm is ${value}")
    sendEvent(name: 'switch', value: active ? 'on' : 'off',
              descriptionText: "${device.displayName} switch is ${active ? 'on' : 'off'}")
    sendEvent(name: 'status', value: active ? 'playing' : 'stopped')
    if (alarmType) { sendEvent(name: 'alarmType', value: alarmType) }
    if (!active) { unschedule('alarmExpired') }
}

/**
 * Command 0x0F on 0xFC11. No manufacturer code - the reference sends this as a
 * plain cluster-specific command, and the inbound frames confirm it (the
 * command id sits at frame byte 2, which is only possible without one).
 */
private List<String> alertCommand(boolean start) {
    String payload = start ? ALERT_START : ALERT_STOP
    logDebug "alertCommand 0x0F payload=${payload} (${start ? 'start' : 'stop'})"
    return zigbee.command(CLUSTER_SONOFF, CMD_ALERT, [:], DELAY_MS, payload)
}

private Integer safeDuration() {
    Integer secs = toInteger(device.currentValue('duration')) ?: 10
    return Math.max(1, Math.min(900, secs))
}

/* ==========================================================================
 * SOUND / VOLUME / DURATION
 * ========================================================================== */

List<String> setSoundType(String type) {
    int idx = SOUND_NAMES.indexOf(type)
    if (idx < 0) {
        log.warn "${device.displayName}: invalid sound type '${type}'"
        return []
    }
    logInfo "setting sound type to ${type}"
    return writeSoundType(idx)
}

private List<String> writeSoundType(int value) {
    List<String> cmds = zigbee.writeAttribute(CLUSTER_SONOFF, ATTR_SOUND_TYPE, DT_ENUM8, value, [:], DELAY_MS)
    cmds += zigbee.readAttribute(CLUSTER_SONOFF, ATTR_SOUND_TYPE, [:], DELAY_MS)
    return cmds
}

/** Named volume, mirrors the device's own four levels. */
List<String> setVolumeLevel(String level) {
    int idx = VOLUME_NAMES.indexOf(level)
    if (idx < 0) {
        log.warn "${device.displayName}: invalid volume level '${level}'"
        return []
    }
    logInfo "setting volume to ${level}"
    return writeVolume(idx)
}

/** AudioVolume capability: 0..100 mapped onto the four device levels. */
List<String> setVolume(volumelevel) {
    Integer pct = toInteger(volumelevel)
    if (pct == null) {
        log.warn "${device.displayName}: invalid volume '${volumelevel}'"
        return []
    }
    pct = Math.max(0, Math.min(100, pct))
    int idx = pct <= 25 ? 0 : (pct <= 50 ? 1 : (pct <= 75 ? 2 : 3))
    logInfo "setting volume to ${pct}% (${VOLUME_NAMES[idx]})"
    return writeVolume(idx)
}

List<String> volumeUp()   { return stepVolume(1) }
List<String> volumeDown() { return stepVolume(-1) }

private List<String> stepVolume(int step) {
    int current = VOLUME_NAMES.indexOf(device.currentValue('volumeLevel'))
    if (current < 0) { current = 0 }
    int idx = Math.max(0, Math.min(VOLUME_NAMES.size() - 1, current + step))
    return writeVolume(idx)
}

private List<String> writeVolume(int value) {
    List<String> cmds = zigbee.writeAttribute(CLUSTER_SONOFF, ATTR_VOLUME, DT_ENUM8, value, [:], DELAY_MS)
    cmds += zigbee.readAttribute(CLUSTER_SONOFF, ATTR_VOLUME, [:], DELAY_MS)
    return cmds
}

/** AudioVolume mute maps onto the device's alarm sound enable flag. */
List<String> mute()   { return setSoundEnable(false) }
List<String> unmute() { return setSoundEnable(true) }

private List<String> setSoundEnable(boolean enabled) {
    logInfo "alarm sound ${enabled ? 'enabled' : 'disabled'}"
    List<String> cmds = zigbee.writeAttribute(CLUSTER_SONOFF, ATTR_SOUND_EN, DT_BOOLEAN, enabled ? 0x01 : 0x00, [mfgCode: MFG_CODE], DELAY_MS)
    cmds += zigbee.readAttribute(CLUSTER_SONOFF, ATTR_SOUND_EN, [mfgCode: MFG_CODE], DELAY_MS)
    return cmds
}

List<String> setDuration(seconds) {
    Integer secs = toInteger(seconds)
    if (secs == null) {
        log.warn "${device.displayName}: invalid duration '${seconds}'"
        return []
    }
    secs = Math.max(1, Math.min(900, secs))
    logInfo "setting duration to ${secs} s"
    // The event is sent from parse() once the read-back confirms the write.
    List<String> cmds = zigbee.writeAttribute(CLUSTER_SONOFF, ATTR_DURATION, DT_UINT16, secs, [:], DELAY_MS)
    cmds += zigbee.readAttribute(CLUSTER_SONOFF, ATTR_DURATION, [:], DELAY_MS)
    return cmds
}

/* ==========================================================================
 * PARSE
 * ========================================================================== */

void parse(String description) {
    if (!description) { return }
    logDebug "parse: ${description}"

    Map msg
    try { msg = zigbee.parseDescriptionAsMap(description) }
    catch (Exception e) {
        log.warn "${device.displayName}: could not parse '${description}': ${e}"
        return
    }
    if (!msg) { return }

    Integer cluster = intOf(msg.clusterInt, msg.cluster)
    if (cluster == null) { return }

    // ZCL Default Response - a rejected command shows up here.
    if (msg.command == '0B') {
        Integer status = hexToInt(msg.data instanceof List ? msg.data[-1] : msg.data)
        if (status != null && status != 0x00) {
            log.warn "${device.displayName}: device rejected a command on cluster ${hex4(cluster)}, ZCL status 0x${hex2(status)}"
        }
        return
    }

    switch (cluster) {
        case CLUSTER_POWER:  parseBattery(msg); break
        case CLUSTER_SONOFF: parseSonoff(msg);  break
        case CLUSTER_BASIC:  break
        default: logDebug "unhandled cluster ${hex4(cluster)}: ${msg}"
    }
}

private void parseBattery(Map msg) {
    Integer attr = intOf(msg.attrInt, msg.attrId)
    if (attr != ATTR_BATTERY_PCT) {
        if (attr == ATTR_BATTERY_VOLT) { logDebug "battery voltage raw=${msg.value}" }
        return
    }
    Integer raw = hexToInt(msg.value)
    if (raw == null) { return }

    // 0xFF means unknown on this device.
    if (raw >= 255) {
        logDebug 'battery report 0xFF (unknown), ignored'
        return
    }
    Integer percent = Math.max(0, Math.min(100, Math.round(raw / 2.0d) as Integer))

    // On external power the device only reports a meaningful 100%.
    if (device.currentValue('powerSource') == 'mains' && percent != 100) {
        logDebug "battery ${percent}% ignored - device is on external power"
        return
    }

    sendEvent(name: 'battery', value: percent, unit: '%',
              descriptionText: "${device.displayName} battery is ${percent}%")
    logInfo "battery is ${percent}% (raw ${raw})"
}

private void parseSonoff(Map msg) {
    // Inbound alert notification: cluster-specific command 0x0F, payload 04 <type>.
    if (msg.command == '0F') {
        parseAlertNotification(msg)
        return
    }

    Integer attr = intOf(msg.attrInt, msg.attrId)
    Integer value = hexToInt(msg.value)
    logDebug "FC11 attr=${hex4(attr)} value=${value} raw=${msg.value} data=${msg.data}"
    if (attr == null || value == null) { return }

    switch (attr) {
        case ATTR_POWER_MODE:
            // Device reports 0=battery, 1=external. Hubitat's PowerSource enum
            // uses "mains" for the latter.
            String source = value == 0x01 ? 'mains' : (value == 0x00 ? 'battery' : 'unknown')
            sendEvent(name: 'powerSource', value: source,
                      descriptionText: "${device.displayName} power source is ${source}")
            // Reference implementation re-reads the battery on this transition.
            if (source == 'mains') {
                runInMillis(500, 'refreshBattery')
            }
            break

        case ATTR_TAMPER:
            String tamper = value == 0x01 ? 'detected' : 'clear'
            sendEvent(name: 'tamper', value: tamper,
                      descriptionText: "${device.displayName} tamper is ${tamper}")
            break

        case ATTR_LIGHT_EN:
            sendEvent(name: 'lightEnabled', value: value == 0x01 ? 'on' : 'off')
            break

        case ATTR_SOUND_EN:
            sendEvent(name: 'soundEnabled', value: value == 0x01 ? 'on' : 'off')
            sendEvent(name: 'mute', value: value == 0x01 ? 'unmuted' : 'muted')
            break

        case ATTR_SOUND_TYPE:
            if (value >= 0 && value < SOUND_NAMES.size()) {
                sendEvent(name: 'soundName', value: SOUND_NAMES[value],
                          descriptionText: "${device.displayName} sound is ${SOUND_NAMES[value]}")
            }
            else { log.warn "${device.displayName}: unknown sound type value 0x${hex2(value)}" }
            break

        case ATTR_VOLUME:
            if (value >= 0 && value < VOLUME_NAMES.size()) {
                sendEvent(name: 'volumeLevel', value: VOLUME_NAMES[value])
                // AudioVolume wants a number: low/medium/high/max -> 25/50/75/100.
                sendEvent(name: 'volume', value: (value + 1) * 25, unit: '%',
                          descriptionText: "${device.displayName} volume is ${VOLUME_NAMES[value]}")
            }
            else { log.warn "${device.displayName}: unknown volume value 0x${hex2(value)}" }
            break

        case ATTR_DURATION:
            Integer secs = value
            // Hubitat normally hands back descMap.value already byte-swapped.
            // If it did not, an implausible value tells us so - swap and warn.
            if (secs > 900 && (msg.value as String)?.length() == 4) {
                Integer swapped = hexToInt(swapOctets(msg.value as String))
                if (swapped != null && swapped <= 900) {
                    log.warn "${device.displayName}: duration raw '${msg.value}' decoded as ${secs}, using byte-swapped ${swapped} - the decoder needs fixing"
                    secs = swapped
                }
            }
            sendEvent(name: 'duration', value: secs, unit: 's',
                      descriptionText: "${device.displayName} alarm duration is ${secs} s")
            break

        default:
            logDebug "unhandled FC11 attribute ${hex4(attr)} = ${value}"
    }
}

/**
 * Inbound alertCommand. Payload is 04 <alarmType>; anything else is not an
 * alert notification. alarmType: 0 none (stopped), 1 manual, 2 scene.
 */
private void parseAlertNotification(Map msg) {
    List data = (msg.data instanceof List) ? msg.data : null
    if (data == null || data.size() < 2) {
        logDebug "alert notification with unexpected payload: ${msg.data}"
        return
    }
    if (hexToInt(data[0]) != 0x04) {
        logDebug "alert notification with unexpected marker: ${msg.data}"
        return
    }
    Integer type = hexToInt(data[1])
    String alarmType = ALARM_TYPES[type]
    if (alarmType == null) {
        logDebug "alert notification with unknown alarm type: ${msg.data}"
        return
    }

    logInfo "device reports alarm ${alarmType == 'none' ? 'stopped' : "started (${alarmType})"}"
    setAlarmState(alarmType != 'none', alarmType)

    if (alarmType != 'none') {
        // Re-arm the safety net against the device's own duration.
        unschedule('alarmExpired')
        runIn(safeDuration() + 3, 'alarmExpired')
    }
}

void refreshBattery() {
    sendZigbeeCommands(zigbee.readAttribute(CLUSTER_POWER, ATTR_BATTERY_PCT, [:], DELAY_MS))
}

/* ==========================================================================
 * HELPERS
 * ========================================================================== */

private Integer toInteger(value) {
    if (value == null) { return null }
    if (value instanceof Number) { return value.intValue() }
    try { return (value.toString().trim() as BigDecimal).intValue() }
    catch (Exception e) { return null }
}

private Integer intOf(intValue, hexValue) {
    if (intValue != null) { return intValue as Integer }
    return hexToInt(hexValue)
}

private Integer hexToInt(value) {
    if (value == null) { return null }
    if (value instanceof Number) { return value as Integer }
    String text = value.toString().trim()
    if (text.startsWith('0x') || text.startsWith('0X')) { text = text.substring(2) }
    if (!text) { return null }
    try { return Integer.parseInt(text, 16) }
    catch (Exception e) { return null }
}

private String swapOctets(String hex) {
    return hex.toList().collate(2)*.join().reverse().join()
}

private String hex2(Integer value) { return value == null ? '??'   : String.format('%02X', value) }
private String hex4(Integer value) { return value == null ? '????' : String.format('%04X', value) }

private void logDebug(String msg) { if (settings?.logEnable) { log.debug "${device.displayName}: ${msg}" } }
private void logInfo(String msg)  { if (settings?.txtEnable != false) { log.info "${device.displayName}: ${msg}" } }

Update 2: Added the 'vibe-coding' tag to this forum thread.

@kkossev Thank you!
Only one correction to your congratulatory statement - it was not AI "Assisted" - it was 100% totally AI because I have no idea what I'm doing!

I'll update with your Claude code!

Thank you @calinatl @kkossev this works perfectly for me.

The Sonoff siren is great, nice and loud with usb c power and a built-in rechargeable battery. Perfect for my needs.