Hi @calinatl ,
Congrats on your first published AI-assisted driver ! 
The best way to publish it in the forum is to use the Preformatted text tool in the editor :
Click on the tool :

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! 
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... 
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.