The strobe on my Heiman HS2WD-E Zigbee siren never worked, and there was no easy way to "chirp" the unit (which is VERY loud).
I worked with ChatGPT to revise the 2019 driver:
- Fixed the strobe (there was a byte ordering problem for durations);
- Added a "Health Check Siren" command (chirps the siren -- still at FULL volume -- for a user-settable duration, defaulting to 50ms);
- Added a "Health Check Strobe" command (flashes the strobe for 3 seconds; the Strobe command now works, but only flashes the LEDs once);
- Added a "Health Check" command that can be run by a rule -- my various health checking apps would sometimes classify this Siren as "inactive", so now I can run a daily rule to make the device "active" without having to set off the siren (although now that the strobe works, I could just use that, but that fix came later).
/**
*
* Copyright 2019 gabriele-v
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
* for the specific language governing permissions and limitations under the License.
*
* Heiman HS2WD-E Siren
*
* Version: 0.7
* 0.1b (2019-01-26) => First release
* 0.2b (2019-01-26) => Bugfixes and new events
* 0.3b (2019-02-14) => Using proper zigbee.parseDescriptionAsMap
* 0.4 (2026-08-03) => Added health-check commands, lastCheckin, and configure()
* 0.5 (2026-08-03) => Corrected IAS WD duration byte order, strobe duty cycle,
* pulse timing, exact device fingerprint, and volume tests
* 0.6 (2026-08-03) => Removed ineffective volume tests, renamed Health Check Pulse
* to Health Check Siren, and made its OFF delay configurable
* 0.7 (2026-08-03) => Expanded Health Check Siren OFF-delay range to 10-1000 ms
* and changed the default to 50 ms
*
* Original author: gabriele-v
* Hubitat updates: OpenAI, based on testing by John Land
*
* Sources:
* ST Siren => https://github.com/SmartThingsCommunity/SmartThingsPublic/blob/master/devicetypes/smartthings/ozom-smart-siren.src/ozom-smart-siren.groovy
*/
metadata {
definition(name: "Heiman HS2WD-E Siren", namespace: "gabriele-v", author: "gabriele-v") {
capability "Alarm"
capability "Switch"
capability "Actuator"
capability "Battery"
capability "Refresh"
capability "Configuration"
command "healthCheckSiren"
command "healthCheckStrobe"
command "healthCheck"
attribute "lastCheckin", "String"
attribute "maxDuration", "Integer"
attribute "hwVer", "String"
// Original fingerprint retained for compatibility with older variants.
fingerprint profileId: "0104", endpointId: "01",
inClusters: "0000,0001,0003,0004,0009,0500,0502",
outClusters: "0003,0019",
manufacturer: "Heiman", model: "WarningDevice"
// Exact fingerprint reported by WarningDevice-EF-3.0.
fingerprint profileId: "0104", endpointId: "01",
inClusters: "0000,0001,0003,0004,0500,0502,0B05",
outClusters: "0019",
manufacturer: "HEIMAN", model: "WarningDevice-EF-3.0",
deviceJoinName: "HEIMAN Warning Device"
}
preferences {
input name: "maxDuration", type: "number",
title: "Max duration of strobe/siren",
range: "1..1800", defaultValue: "240", required: true
input name: "healthCheckSirenDelayMs", type: "number",
title: "Health Check Siren OFF delay (milliseconds)",
description: "Time between siren ON and explicit OFF; very short values may be limited by Zigbee/device processing",
range: "10..1000", defaultValue: "50", required: true
input name: "infoLogging", type: "bool",
title: "Enable info message logging", description: ""
input name: "debugLogging", type: "bool",
title: "Enable debug message logging", description: ""
}
}
// Parse events into attributes.
def parse(String description) {
displayDebugLog("Parsing message: ${description}")
String checkinTime = new Date().format(
"yyyy-MM-dd HH:mm:ss",
location.timeZone
)
sendEvent(
name: "lastCheckin",
value: checkinTime,
isStateChange: true
)
Map map = [:]
if (description?.startsWith("read attr -")) {
Map descMap = zigbee.parseDescriptionAsMap(description)
displayDebugLog("Desc Map: ${descMap}")
if (descMap.cluster == "0000" && descMap.attrId == "0003") {
displayDebugLog("RAW HW VER: ${descMap.value}")
map = [
name: "hwVer",
value: descMap.value,
descriptionText: "Hardware version is ${descMap.value}"
]
}
else if (descMap.cluster == "0001" && descMap.attrId == "0021") {
displayDebugLog("RAW BATTERY PERCENTAGE: ${descMap.value}")
Integer retValue = Integer.parseInt(descMap.value, 16) - 100
map = [
name: "battery",
value: retValue,
unit: "%",
descriptionText: "Battery level is ${retValue}%"
]
}
else if (descMap.cluster == "0502" && descMap.attrId == "0000") {
displayDebugLog("RAW MAX DURATION: ${descMap.value}")
Integer retValue = Integer.parseInt(descMap.value, 16)
map = [
name: "maxDuration",
value: retValue,
descriptionText: "Max siren/strobe duration is ${retValue}"
]
}
}
if (map != [:]) {
displayInfoLog("${map.name} => ${map.value} (${map.descriptionText})")
displayDebugLog("Creating event ${map}")
return createEvent(map)
}
return [:]
}
private void displayDebugLog(message) {
if (debugLogging) {
log.debug "${device.displayName}: ${message}"
}
}
private void displayInfoLog(message) {
if (infoLogging || state.prefsSetCount != 1) {
log.info "${device.displayName}: ${message}"
}
}
def updated() {
displayDebugLog("updated called")
displayInfoLog("maxDuration : ${settings.maxDuration}")
List<String> cmds =
zigbee.writeAttribute(
0x0502,
0x0000,
DataType.UINT16,
(int) settings.maxDuration
) +
zigbee.readAttribute(0x0502, 0x0000)
displayInfoLog("updated() --- cmds: ${cmds}")
return cmds
}
def refresh() {
List<String> cmds =
zigbee.readAttribute(0x0000, 0x0003) + // Hardware version
zigbee.readAttribute(0x0001, 0x0021) + // Battery percentage
zigbee.readAttribute(0x0502, 0x0000) // Maximum warning duration
displayInfoLog("refresh() --- cmds: ${cmds}")
return cmds
}
def configure() {
displayInfoLog("configure called")
return refresh()
}
def off() {
return warningCommand("00", 0, "00", "00")
}
def on() {
sendEvent(
name: "alarm",
value: "on",
descriptionText: "Device alarming on",
type: "digital"
)
return both()
}
def both() {
sendEvent(
name: "alarm",
value: "both",
descriptionText: "Device alarming with siren and strobe",
type: "digital"
)
// 0x17: burglar warning + strobe + requested very-high siren level.
return warningCommand("17", 1, "32", "03")
}
def strobe() {
sendEvent(
name: "alarm",
value: "strobe",
descriptionText: "Device alarming with strobe",
type: "digital"
)
// 0x04: no audible warning + strobe enabled.
return warningCommand("04", 1, "32", "03")
}
def siren() {
sendEvent(
name: "alarm",
value: "siren",
descriptionText: "Device alarming with siren",
type: "digital"
)
// 0x13: burglar warning + no strobe + requested very-high siren level.
return warningCommand("13", 1, "00", "00")
}
/**
* Brief audible health-check test.
*
* Requests a one-second warning as a safety fallback, then transmits an
* explicit OFF command after the configured millisecond delay. The default is
* 50 ms. Actual audible duration can be longer because Zigbee transmission
* and device processing are not real-time.
*
* zigbee.command() normally includes a trailing "delay 2000" token. Those
* helper-generated delays are removed before inserting the intended custom
* delay between ON and OFF.
*/
def healthCheckSiren() {
Integer offDelayMs = normalizedHealthCheckSirenDelay()
displayInfoLog(
"Health-check siren: OFF approximately ${offDelayMs} ms after ON"
)
List<String> startCmd = removeDelayTokens(
warningCommand("13", 1, "00", "00")
)
List<String> stopCmd = removeDelayTokens(
warningCommand("00", 0, "00", "00")
)
List<String> cmds = startCmd + ["delay ${offDelayMs}"] + stopCmd
displayDebugLog("healthCheckSiren() --- cmds: ${cmds}")
return cmds
}
def healthCheckStrobe() {
Integer seconds = 3
String durationBytes = uint16LittleEndian(seconds)
displayInfoLog(
"Health-check strobe: ${seconds} seconds at 50% duty cycle; " +
"duration payload ${durationBytes}"
)
// 0x04: no audible warning + strobe enabled.
return warningCommand("04", seconds, "32", "03")
}
def healthCheck() {
displayInfoLog("Silent health check requested")
return refresh()
}
/**
* Builds an IAS Warning Device Start Warning command.
* warningInfo, strobeDutyCycle, and strobeLevel are one-byte fields.
* warningDuration is a two-byte little-endian UINT16.
*/
private List<String> warningCommand(
String warningInfo,
Integer durationSeconds,
String strobeDutyCycle,
String strobeLevel
) {
return zigbee.command(
0x0502,
0x00,
warningInfo,
uint16LittleEndian(durationSeconds),
strobeDutyCycle,
strobeLevel
)
}
/**
* Removes Hubitat helper delay tokens so a custom inter-command delay can be
* used. This is needed by healthCheckSiren(); otherwise the default two-second
* delay prevents OFF from following ON after the configured short delay.
*/
private List<String> removeDelayTokens(List<String> commands) {
return commands.findAll { String command ->
command && !command.toLowerCase().startsWith("delay")
}
}
private Integer normalizedHealthCheckSirenDelay() {
Integer configuredDelay = settings.healthCheckSirenDelayMs != null ?
(settings.healthCheckSirenDelayMs as Integer) : 50
return Math.max(10, Math.min(1000, configuredDelay))
}
private String uint16LittleEndian(Integer value) {
if (value == null || value < 0 || value > 65535) {
throw new IllegalArgumentException(
"UINT16 value must be between 0 and 65535"
)
}
return String.format(
"%02X%02X",
value & 0xFF,
(value >> 8) & 0xFF
)
}