Created
May 14, 2026 09:58
-
-
Save makleso6/960f8c21e42dffeeebf67553d9e00a99 to your computer and use it in GitHub Desktop.
znsmbl11lm.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ============================================================================= | |
| // zigbee2mqtt external converter for Aqara Smart Sleep Monitor (ZNSMBL11LM) | |
| // Zigbee model id: lumi.lunar.acn01 | |
| // ============================================================================= | |
| // | |
| // Drop into z2m's `data/external_converters/` directory, restart z2m, pair the | |
| // device. Exposes: heart_rate, respiratory_rate, sleep_state, in_bed, | |
| // body_movement, plus 4 alarm thresholds (HR high/low, RR high/low). | |
| // | |
| // Reverse-engineering notes: | |
| // - All sensor data lives in cluster 0xFCC0 (manuSpecificLumi) | |
| // - 0x01A0/0x01A6/0x01A7 + thresholds are read/reported WITHOUT mfgCode | |
| // (Aqara quirk — same cluster, mixed mfgCode semantics) | |
| // - 0x0122 (presence) IS mfg-specific (0x115F), in_bed = 1, dips 1→0→1 | |
| // within ~6 sec correspond to body movements | |
| // - 0xFFF2 streaming payload: frame length === 48 bytes (vs idle 43) signals | |
| // a body movement event (~5x more sensitive than 0x0122 dips) | |
| // - configureReporting() returns FAILURE on this device — Aqara pushes | |
| // unsolicited reports every ~6 seconds anyway, so just bind() and listen | |
| // - Light/Deep/REM enum from MIoT spec is NOT produced by the device; | |
| // Aqara cloud computes it from raw HR+RR+movements. Sleep state is binary. | |
| // | |
| // Tested against: firmware 31 / DateCode 20200819, captured 5138 reports | |
| // over a 3.5h overnight session. | |
| // | |
| // ============================================================================= | |
| const exposes = require('zigbee-herdsman-converters/lib/exposes'); | |
| const e = exposes.presets; | |
| const ea = exposes.access; | |
| // Attribute ids on manuSpecificLumi (0xFCC0) | |
| const ATTR_SLEEP_STATE = 0x01A0; // uint8: 1=awake, 2=asleep (no mfgCode) | |
| const ATTR_SLEEP_STATE_MIRROR = 0x01A1; // sync mirror of 0x01A0 (no mfgCode) | |
| const ATTR_HR_HIGH_THR = 0x01A2; // uint8 bpm default 100 (no mfgCode) | |
| const ATTR_HR_LOW_THR = 0x01A3; // uint8 bpm default 50 (no mfgCode) | |
| const ATTR_RR_HIGH_THR = 0x01A4; // uint8 /min default 20 (no mfgCode) | |
| const ATTR_RR_LOW_THR = 0x01A5; // uint8 /min default 12 (no mfgCode) | |
| const ATTR_HEART_RATE = 0x01A6; // uint8 bpm current (no mfgCode) | |
| const ATTR_RESP_RATE = 0x01A7; // uint8 /min current (no mfgCode) | |
| const ATTR_PRESENCE = 0x0122; // uint8 0/1 in-bed (mfgCode 0x115F) | |
| const ATTR_FFF2_RAW = 0xFFF2; // OctetString streaming payload (mfgCode 0x115F) | |
| const AQARA_MFG_CODE = 0x115F; | |
| // Detection window for "body movement" via 0x0122 dip 1→0→1 | |
| const PRESENCE_DIP_WINDOW_MS = 10_000; | |
| // How long body_movement stays true before auto-resetting | |
| const MOVEMENT_HOLD_MS = 2_000; | |
| // ----------------------------------------------------------------------------- | |
| // fromZigbee — incoming attribute reports | |
| // ----------------------------------------------------------------------------- | |
| const fzLumiLunarAcn01 = { | |
| cluster: 'manuSpecificLumi', | |
| type: ['attributeReport', 'readResponse'], | |
| convert: (model, msg, publish, options, meta) => { | |
| const result = {}; | |
| const data = msg.data; | |
| const now = Date.now(); | |
| // ------- vital signs (HR / RR) --------------------------------------- | |
| if (data[ATTR_HEART_RATE] !== undefined) { | |
| result.heart_rate = data[ATTR_HEART_RATE]; | |
| } | |
| if (data[ATTR_RESP_RATE] !== undefined) { | |
| result.respiratory_rate = data[ATTR_RESP_RATE]; | |
| } | |
| // ------- sleep state (binary awake/asleep) --------------------------- | |
| if (data[ATTR_SLEEP_STATE] !== undefined) { | |
| const v = data[ATTR_SLEEP_STATE]; | |
| result.sleep_state = v === 2 ? 'asleep' : v === 1 ? 'awake' : 'unknown'; | |
| } | |
| // ------- thresholds (settings echo back on read) --------------------- | |
| if (data[ATTR_HR_HIGH_THR] !== undefined) result.heart_rate_high_threshold = data[ATTR_HR_HIGH_THR]; | |
| if (data[ATTR_HR_LOW_THR] !== undefined) result.heart_rate_low_threshold = data[ATTR_HR_LOW_THR]; | |
| if (data[ATTR_RR_HIGH_THR] !== undefined) result.respiratory_rate_high_threshold = data[ATTR_RR_HIGH_THR]; | |
| if (data[ATTR_RR_LOW_THR] !== undefined) result.respiratory_rate_low_threshold = data[ATTR_RR_LOW_THR]; | |
| // ------- in_bed presence + dip-based body movement ------------------- | |
| if (data[ATTR_PRESENCE] !== undefined) { | |
| const v = data[ATTR_PRESENCE]; | |
| result.in_bed = v === 1; | |
| // Track previous value to detect dips 1→0→1 = body movement | |
| const cache = (meta.state || {}); | |
| const lastV = cache._aqara_presence_last; | |
| const lastT = cache._aqara_presence_last_t || 0; | |
| if (lastV === 0 && v === 1 && (now - lastT) < PRESENCE_DIP_WINDOW_MS) { | |
| // Belt regained chest contact after a short loss → user moved | |
| result.body_movement = true; | |
| scheduleMovementReset(publish, meta); | |
| } | |
| cache._aqara_presence_last = v; | |
| cache._aqara_presence_last_t = now; | |
| if (meta.state) Object.assign(meta.state, cache); | |
| } | |
| // ------- FFF2 frame-length-based body movement ------------------------ | |
| // Per reverse-engineering: total frame length >= 48 bytes signals | |
| // a body movement event (vs 43 bytes for idle frames). | |
| // This catches ~5x more events than 0x0122 dips — finer granularity. | |
| if (data[ATTR_FFF2_RAW] !== undefined) { | |
| const raw = data[ATTR_FFF2_RAW]; | |
| const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw || []); | |
| if (buf.length >= 48) { | |
| result.body_movement = true; | |
| scheduleMovementReset(publish, meta); | |
| } | |
| } | |
| return result; | |
| }, | |
| }; | |
| // Helper: clear body_movement after MOVEMENT_HOLD_MS | |
| function scheduleMovementReset(publish, meta) { | |
| const cache = (meta.state || {}); | |
| if (cache._aqara_movement_timer) clearTimeout(cache._aqara_movement_timer); | |
| cache._aqara_movement_timer = setTimeout(() => { | |
| publish({body_movement: false}); | |
| }, MOVEMENT_HOLD_MS); | |
| if (meta.state) Object.assign(meta.state, cache); | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // toZigbee — writes for the 4 alarm thresholds | |
| // ----------------------------------------------------------------------------- | |
| function makeThresholdConverter(key, attrId) { | |
| return { | |
| key: [key], | |
| convertSet: async (entity, _key, value, _meta) => { | |
| const v = parseInt(value, 10); | |
| await entity.write('manuSpecificLumi', {[attrId]: {value: v, type: 0x20}}); | |
| return {state: {[key]: v}}; | |
| }, | |
| convertGet: async (entity, _key, _meta) => { | |
| await entity.read('manuSpecificLumi', [attrId]); | |
| }, | |
| }; | |
| } | |
| const tzHrHighThreshold = makeThresholdConverter('heart_rate_high_threshold', ATTR_HR_HIGH_THR); | |
| const tzHrLowThreshold = makeThresholdConverter('heart_rate_low_threshold', ATTR_HR_LOW_THR); | |
| const tzRrHighThreshold = makeThresholdConverter('respiratory_rate_high_threshold', ATTR_RR_HIGH_THR); | |
| const tzRrLowThreshold = makeThresholdConverter('respiratory_rate_low_threshold', ATTR_RR_LOW_THR); | |
| // ----------------------------------------------------------------------------- | |
| // Definition | |
| // ----------------------------------------------------------------------------- | |
| const definition = { | |
| zigbeeModel: ['lumi.lunar.acn01'], | |
| model: 'ZNSMBL11LM', | |
| vendor: 'Aqara', | |
| description: 'Smart sleep monitoring belt', | |
| fromZigbee: [fzLumiLunarAcn01], | |
| toZigbee: [tzHrHighThreshold, tzHrLowThreshold, tzRrHighThreshold, tzRrLowThreshold], | |
| // configureReporting() returns FAILURE on Aqara FCC0 mfg-specific attrs. | |
| // We just bind the cluster — the device pushes unsolicited reports | |
| // every ~6 seconds on its own. | |
| configure: async (device, coordinatorEndpoint, logger) => { | |
| const ep = device.getEndpoint(1); | |
| try { | |
| await ep.bind('manuSpecificLumi', coordinatorEndpoint); | |
| } catch (err) { | |
| logger.debug(`[lumi.lunar.acn01] bind failed: ${err.message} (continuing — device auto-reports)`); | |
| } | |
| // Initial threshold read (best effort — device may not respond on first try) | |
| try { | |
| await ep.read('manuSpecificLumi', | |
| [ATTR_HR_HIGH_THR, ATTR_HR_LOW_THR, ATTR_RR_HIGH_THR, ATTR_RR_LOW_THR]); | |
| } catch (err) { | |
| logger.debug(`[lumi.lunar.acn01] initial threshold read failed: ${err.message}`); | |
| } | |
| }, | |
| exposes: [ | |
| // ----- vital signs ----- | |
| e.numeric('heart_rate', ea.STATE) | |
| .withUnit('bpm') | |
| .withDescription('Heart rate (resting 50-100, alarm thresholds configurable)'), | |
| e.numeric('respiratory_rate', ea.STATE) | |
| .withUnit('/min') | |
| .withDescription('Respiratory rate (resting 12-20)'), | |
| // ----- sleep / presence ----- | |
| e.enum('sleep_state', ea.STATE, ['awake', 'asleep', 'unknown']) | |
| .withDescription('Sleep state — device produces only binary awake/asleep. ' + | |
| 'Light/Deep/REM are not exposed by the firmware (cloud-side analytics).'), | |
| e.binary('in_bed', ea.STATE, true, false) | |
| .withDescription('Person on belt (in bed)'), | |
| e.binary('body_movement', ea.STATE, true, false) | |
| .withDescription('Body movement detected (auto-resets after ' + (MOVEMENT_HOLD_MS / 1000) + 's)'), | |
| // ----- alarm thresholds (settable) ----- | |
| e.numeric('heart_rate_high_threshold', ea.ALL) | |
| .withUnit('bpm').withValueMin(50).withValueMax(200).withValueStep(1) | |
| .withDescription('Alarm if HR exceeds this value'), | |
| e.numeric('heart_rate_low_threshold', ea.ALL) | |
| .withUnit('bpm').withValueMin(30).withValueMax(100).withValueStep(1) | |
| .withDescription('Alarm if HR falls below this value'), | |
| e.numeric('respiratory_rate_high_threshold', ea.ALL) | |
| .withUnit('/min').withValueMin(10).withValueMax(40).withValueStep(1) | |
| .withDescription('Alarm if RR exceeds this value'), | |
| e.numeric('respiratory_rate_low_threshold', ea.ALL) | |
| .withUnit('/min').withValueMin(5).withValueMax(25).withValueStep(1) | |
| .withDescription('Alarm if RR falls below this value'), | |
| ], | |
| meta: {}, | |
| }; | |
| module.exports = definition; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment