Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save makleso6/960f8c21e42dffeeebf67553d9e00a99 to your computer and use it in GitHub Desktop.

Select an option

Save makleso6/960f8c21e42dffeeebf67553d9e00a99 to your computer and use it in GitHub Desktop.
znsmbl11lm.js
// =============================================================================
// 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