Skip to content

[External Converter]: Updated HT-SLM-2 from Heimgard Technologies #31179

@monsivar

Description

@monsivar

Link

https://www.zigbee2mqtt.io/devices/HT-SLM-2.html

Database entry

{ "id": 99, "type": "EndDevice", "ieeeAddr": "0x0000000000000000", "nwkAddr": 12345, "manufId": 4098, "manufName": "Heimgard Technologies", "powerSource": "Battery", "modelId": "HT-SLM-2", "epList": [ 1 ]

Zigbee2MQTT version

2.8.0

External converter

const { Zcl } = require('zigbee-herdsman');
const exposes = require("zigbee-herdsman-converters/lib/exposes");
const utils = require("zigbee-herdsman-converters/lib/utils");
const reporting = require("zigbee-herdsman-converters/lib/reporting");
const fz = require('zigbee-herdsman-converters/converters/fromZigbee');
const tz = require('zigbee-herdsman-converters/converters/toZigbee');
const ea = exposes.access;
const e = exposes.presets;

// Function to identify which lock (interior or exterior) was last changed
function identifyTargetLock(lockHistory) {
    const filteredHistory = [];
    let lastEntry = null;

    for (let entry of lockHistory) {
        if (!lastEntry || entry.interiorLockState !== lastEntry.interiorLockState || entry.exteriorLockState !== lastEntry.exteriorLockState) {
            filteredHistory.push(entry);
        }
        lastEntry = entry;
    }

    let lastInteriorChange = null;
    let lastExteriorChange = null;

    for (let i = 1; i < filteredHistory.length; i++) {
        const prevEntry = filteredHistory[i - 1];
        const currentEntry = filteredHistory[i];

        if (prevEntry.interiorLockState === "locked" && currentEntry.interiorLockState === "unlocked") {
            lastInteriorChange = currentEntry;
        }
        if (prevEntry.exteriorLockState === "locked" && currentEntry.exteriorLockState === "unlocked") {
            lastExteriorChange = currentEntry;
        }
    }

    if (lastInteriorChange && lastExteriorChange) {
        return lastInteriorChange.time > lastExteriorChange.time ? 'internal' : 'external';
    } else if (lastInteriorChange) {
        return 'internal';
    } else if (lastExteriorChange) {
        return 'external';
    } else {
        return "catch22"; 
    }
}

const fzLocal = {
    heimgard_lock: {
        cluster: 'closuresDoorLock',
        type: ['attributeReport', 'readResponse'],
        convert: (model, msg, publish, options, meta) => {
            const result = {};
            let { isInteriorLocked, isExteriorLocked, lockHistory = [], safety_locking } = meta.state;
            const enforceLockingIfBogus = safety_locking === true || safety_locking === 'Enabled';

            if (safety_locking !== undefined) {
                result.safety_locking = enforceLockingIfBogus ? "Enabled" : "Disabled";
            }

            if (msg.data["lockState"] !== undefined) {
                result.state = msg.data["lockState"] === 1 ? 'LOCK' : 'UNLOCK';
                if ((lockHistory.length === 0 || (isInteriorLocked && isExteriorLocked) || (!isInteriorLocked && !isExteriorLocked)) && enforceLockingIfBogus) {
                    tz.lock.convertSet(msg.endpoint, "state", "LOCK", meta);
                }
            }
            return result;
        }
    },
    heimgard_lock_source: {
        cluster: 'closuresDoorLock',
        type: ['commandOperationEventNotification'],
        convert: (model, msg, publish, options, meta) => {
            const result = {};
            const lockStateCode = msg.data["opereventcode"];
            const lockChangeRequester = msg.data["opereventsrc"];

            const lockUnlockSource = utils.getFromLookup(lockChangeRequester, {
                0: "pin", 1: "remote", 2: "function_key", 3: "rfid_tag", 4: "fingerprint", 255: "self"
            });

            if (typeof lockUnlockSource !== 'string') return result;

            const isLocked = (lockStateCode == 1);
            const isInteriorTriggered = (lockChangeRequester === 2); 
            const isAutoLockedByDevice = (lockChangeRequester === 255); 

            let { isInteriorLocked, isExteriorLocked, lockHistory = [], pin_name_mapping = {} } = meta.state;

            if (isAutoLockedByDevice) {
                const targetLock = identifyTargetLock(lockHistory);
                if (targetLock === 'internal') {
                    isInteriorLocked = isLocked;
                    result.inner_lock_state = 'locked';
                } else if (targetLock === 'external') {
                    isExteriorLocked = isLocked;
                    result.lock_state = 'locked';
                    result.state = isLocked ? 'LOCK' : 'UNLOCK';
                    meta.state.state = result.state;
                } else {
                    tz.lock.convertSet(msg.endpoint, "state", "LOCK", meta);
                    return;
                }
            } else {
                if (isInteriorTriggered) {
                    isInteriorLocked = isLocked;
                    result.inner_lock_state = isLocked ? 'locked' : 'unlocked';
                } else {
                    const userId = msg.data["userid"];
                    const userName = userId === 0 ? "Master" : pin_name_mapping[userId] || `Unknown (${userId})`;

                    if (!isLocked) {
                        result.last_unlock_source_raw = lockChangeRequester;
                        result.last_unlock_source = lockUnlockSource;
                        result.last_unlock_by_user = userName;
                    } else {
                        result.last_lock_source_raw = lockChangeRequester;
                        result.last_lock_source = lockUnlockSource;
                        result.last_lock_by_user = userName;
                    }
                    isExteriorLocked = isLocked;
                    result.lock_state = utils.getFromLookup(lockStateCode, {0: "unknown_lock_failure", 1: 'locked', 2: 'unlocked'}, 0);
                    result.state = isLocked ? 'LOCK' : 'UNLOCK';
                    meta.state.state = result.state;
                }
            }

            const historyEntry = {
                time: Number(new Date()),
                interiorLockState: isInteriorLocked ? 'locked' : 'unlocked',
                exteriorLockState: isExteriorLocked ? 'locked' : 'unlocked',
            };
            lockHistory.push(historyEntry);
            meta.state.lockHistory = lockHistory.slice(-5);
            meta.state.isInteriorLocked = isInteriorLocked;
            meta.state.isExteriorLocked = isExteriorLocked;

            return result;
        }
    },
    heimgard_volume: {
        cluster: 'closuresDoorLock',
        type: ['attributeReport', 'readResponse'],
        convert: (model, msg, publish, options, meta) => {
            if (msg.data["soundVolume"] !== undefined) {
                return { sound_volume: utils.getFromLookup(msg.data["soundVolume"], {0: "off", 1: "low", 2: "medium", 3: "high"}) };
            }
        }
    },
    heimgard_local_programming: {
        cluster: 'closuresDoorLock',
        type: ['attributeReport', 'readResponse'],
        convert: (model, msg, publish, options, meta) => {
            if (msg.data["enableLocalProgramming"] !== undefined) {
                return { local_programming: msg.data["enableLocalProgramming"] ? 'Enabled' : 'Disabled' };
            }
            if (msg.data[0x0028] !== undefined) {
                return { local_programming: msg.data[0x0028] ? 'Enabled' : 'Disabled' };
            }
        }
    }
}

const tzLocal = {
    innerLock: {
        key: ['inner_lock_state'],
        convertGet: async (entity, key, meta) => {
            await entity.read('closuresDoorLock', [0x0009]);
        },
    },
    soundVolume: {
        key: ['sound_volume'],
        convertSet: async (entity, key, value, meta) => {
            const payload = utils.getFromLookup(value, {off: 0, low: 1, medium: 2, high: 3});
            await entity.write('closuresDoorLock', {0x0024: {value: payload, type: Zcl.DataType.UINT8}});
        },
        convertGet: async (entity, key, meta) => {
            await entity.read('closuresDoorLock', ['soundVolume']);
        },
    },
    localProgramming: {
        key: ['local_programming'],
        convertSet: async (entity, key, value, meta) => {
            const isEnabled = (value === 'Enabled' || value === true || value === 'ON');
            const payload = isEnabled ? 1 : 0;
            await entity.write('closuresDoorLock', {0x0028: {value: payload, type: Zcl.DataType.BOOLEAN}});
            return { state: { local_programming: isEnabled ? 'Enabled' : 'Disabled' } };
        },
        convertGet: async (entity, key, meta) => {
            await entity.read('closuresDoorLock', [0x0028]);
        },
    },
    antiBogus: {
        key: ['safety_locking'],
        convertSet: async (entity, key, value, meta) => {
            const isEnabled = (value === 'Enabled' || value === true || value === 'ON');
            meta.state.safety_locking = isEnabled;
            await entity.read('closuresDoorLock', ['lockState']);
            return { state: { safety_locking: isEnabled ? 'Enabled' : 'Disabled' } };
        },
        convertGet: async (entity, key, meta) => {
            await entity.read('closuresDoorLock', ['lockState']);
        },
    },
    pinNameMapping: {
        key: ['pin_name_mapping'],
        convertSet: async (entity, key, value, meta) => {
            meta.state.pin_name_mapping = value;
            return {state: {pin_name_mapping: value}};
        },
        convertGet: async (entity, key, meta) => {
            return {pin_name_mapping: meta.state.pin_name_mapping || {}};
        },
    },
    pinNames: {
        key: Array.from({length: 40}, (_, i) => `pin_${i}_name`),
        convertSet: async (entity, key, value, meta) => {
            const pinIndex = parseInt(key.split('_')[1], 10);
            if (pinIndex === 0) throw new Error("PIN 0 (Master) cannot be renamed.");
            if (!meta.state.pin_name_mapping) meta.state.pin_name_mapping = {};
            meta.state.pin_name_mapping[pinIndex] = value;
            return {state: {[key]: value}};
        },
        convertGet: async (entity, key, meta) => {
            const pinIndex = parseInt(key.split('_')[1], 10);
            const pinName = pinIndex === 0 ? "Master" : meta.state.pin_name_mapping?.[pinIndex] || '';
            return {[key]: pinName};
        },
    },
}

const definition = {
    zigbeeModel: ['HT-SLM-2'],
    model: 'HT-SLM-2',
    vendor: 'Heimgard Technologies',
    description: 'Smart Door Lock HT-SLM-2',
    extend: [],
    meta: {},
    fromZigbee: [
        fz.battery,
        fz.lock_pin_code_response,
        fzLocal.heimgard_volume, 
        fzLocal.heimgard_lock, 
        fzLocal.heimgard_lock_source,
        fzLocal.heimgard_local_programming
    ],
    toZigbee: [
        tz.lock,
        tz.pincode_lock,
        tzLocal.innerLock, 
        tzLocal.soundVolume, 
        tzLocal.antiBogus, 
        tzLocal.pinNameMapping, 
        tzLocal.pinNames,
        tzLocal.localProgramming
    ],
    configure: async (device, coordinatorEndpoint, _logger) => {
        const endpoint = device.getEndpoint(1);
        const binds = ['genPowerCfg', 'closuresDoorLock'];
        
        await reporting.bind(endpoint, coordinatorEndpoint, binds);
        await reporting.lockState(endpoint);
        await reporting.batteryPercentageRemaining(endpoint);
        
        try {
            await endpoint.read('closuresDoorLock', ['soundVolume', 0x0028]);
        } catch (error) {
            _logger.debug(`Failed to read initial configuration: ${error}`);
        }
        
        device.powerSource = "Battery";
        
        if (!device.meta.pin_name_mapping) {
            device.meta.pin_name_mapping = {};
        }
        
        device.save();
        _logger.info(`Heimgard HT-SLM-2 configured successfully.`);
    },
    exposes: [
        e.lock(),
        e.pincode(),
        e.enum('last_unlock_source', ea.STATE, ["pin", "remote", "function_key", "rfid_tag", "fingerprint", "self"]).withDescription("Source of last unlock"),
        e.text('last_unlock_by_user', ea.STATE).withDescription("Last user that opened the lock"),
        e.enum('inner_lock_state', ea.STATE, ['LOCK', 'UNLOCK', 'UNKNOWN']),
        e.battery(),

        e.enum('sound_volume', ea.ALL, ['off', 'low', 'medium', 'high'])
            .withDescription("Sound volume of the lock")
            .withCategory('config'),
            
        e.binary('local_programming', ea.ALL, 'Enabled', 'Disabled')
            .withLabel("Local Programming")
            .withDescription("Enable or disable the ability to program the lock via its physical keypad.")
            .withCategory('config'),
        
        e.binary('safety_locking', ea.ALL, 'Enabled', 'Disabled')
            .withLabel("Enforced locking")
            .withDescription("Enforcing locking of the device if the state is bogus.")
            .withCategory('config'),

        ...Array.from({length: 40}, (_, i) =>
            i === 0
                ? e.text(`pin_${i}_name`, ea.STATE_SET)
                    .withDescription(`Name for PIN ${i} (Master, cannot be changed)`)
                    .withCategory('config')
                : e.text(`pin_${i}_name`, ea.STATE_SET)
                    .withDescription(`Name for PIN ${i}`)
                    .withCategory('config')
        ),
    ]
};

module.exports = definition;

What does/doesn't work with the external definition?

HT-SLM-2_Zigbee_Cluster_Specification.md

The current built-in support for the Heimgard HT-SLM-2 smart lock is quite basic. It currently only exposes the lock state, battery, standard pin code management, and volume.

However, this lock has advanced hardware (distinguishing between the inner thumb-turn and outer keypad/motor) and a lot of specific reporting that isn't utilized. I am not a developer myself, but with the help of community snippets, AI, and the official Zigbee Cluster Specifications from the manufacturer, I have put together an external converter that unlocks its full potential.

What this external converter adds/fixes:

Unlock Source & User: Exposes last_unlock_source (PIN, RFID, Fingerprint, Remote, etc.) and last_unlock_by_user.

PIN Name Mapping: Adds 40 user slots (pin_0_name to pin_39_name) to map User IDs to actual names (e.g., ID 2 = "Bob"), which are categorized as 'config' to keep the Home Assistant dashboard clean.

Inner Lock State: Exposes inner_lock_state to show if the door was operated from the inside vs. the outside.

Local Programming Toggle: Exposes the local_programming attribute (0x0028) as a config toggle, allowing users to disable physical keypad programming for added security.

Enforced Safety Locking: Adds an anti-bogus safety_locking logic that forces the lock into a locked state if it reports ambiguous states (with a fix for the UI boolean error).

HA Cleanliness: Moves all config toggles (Volume, Local Programming, Safety Locking, PIN names) into the config category so they don't clutter the main HA dashboard.

Note: Based on the official cluster specs, the 2-minute auto-relock is a local hardware feature and not supported via the AutoRelockTime Zigbee cluster, so auto_relock_time and door_state have been intentionally omitted from this converter as they do not work.

Testing:
I have been running this external converter locally and all states, PIN mappings, and toggles are reflecting correctly in Home Assistant via MQTT. Would appreciate it if someone could review and integrate this into the main herdsman-converters repository. Thanks!

Notes

software_build_id: undefined
date_code: undefined
endpoints:

{"1":{"clusters":{"input":["genBasic","genPowerCfg","genIdentify","genGroups","genScenes","genPollCtrl","closuresDoorLock"],"output":["genIdentify","genOta"]}}}

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions