Heiman HS2WD-E Zigbee siren driver UPDATED 2026-08-03

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

2 Likes