Skip to content

Commit fccce0c

Browse files
fix: normalize ZNCLBL01LM terminal drift
1 parent f1531c7 commit fccce0c

2 files changed

Lines changed: 282 additions & 2 deletions

File tree

src/lib/lumi.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ const NS = "zhc:lumi";
4747
const e = exposes.presets;
4848
const ea = exposes.access;
4949
const ZNCLBL01LM_RUNNING_STORE_KEY = "ZNCLBL01LM_running";
50+
const ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY = "ZNCLBL01LM_terminal_position";
51+
const ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY = "ZNCLBL01LM_terminal_target_position";
5052

5153
declare type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
5254

@@ -876,7 +878,9 @@ export const numericAttributes2Payload = async (
876878
case "1055":
877879
if (["ZNCLBL01LM"].includes(model.model)) {
878880
assertNumber(value);
879-
payload.target_position = options.invert_cover ? 100 - value : value;
881+
const targetPosition = options.invert_cover ? 100 - value : value;
882+
rememberZNCLBL01LMTerminalTargetPosition(targetPosition, model, msg.endpoint, false);
883+
payload.target_position = normalizeZNCLBL01LMTerminalPosition(targetPosition, model, msg.endpoint);
880884
}
881885
break;
882886
case "1056":
@@ -896,6 +900,9 @@ export const numericAttributes2Payload = async (
896900
assertNumber(value);
897901
payload.running = value < 2;
898902
globalStore.putValue(msg.endpoint, ZNCLBL01LM_RUNNING_STORE_KEY, payload.running);
903+
if (payload.running) {
904+
globalStore.clearValue(msg.endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY);
905+
}
899906

900907
// https://github.com/Koenkk/zigbee-herdsman-converters/pull/11911
901908
if (!payload.running && previousRunning !== false) {
@@ -995,6 +1002,52 @@ export const numericAttributes2Payload = async (
9951002
return payload;
9961003
};
9971004

1005+
function normalizeZNCLBL01LMTerminalPosition(position: number, model: Definition, endpoint: Zh.Endpoint | Zh.Group): number {
1006+
if (!["ZNCLBL01LM"].includes(model.model)) {
1007+
return position;
1008+
}
1009+
1010+
if (position === 0 || position === 100) {
1011+
globalStore.putValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY, position);
1012+
return position;
1013+
}
1014+
1015+
if (globalStore.getValue(endpoint, ZNCLBL01LM_RUNNING_STORE_KEY, undefined) !== false) {
1016+
return position;
1017+
}
1018+
1019+
const terminalPosition =
1020+
globalStore.getValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY, undefined) ??
1021+
globalStore.getValue(endpoint, ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY, undefined);
1022+
if (terminalPosition === 100 && position >= 98 && position < 100) {
1023+
return 100;
1024+
}
1025+
if (terminalPosition === 0 && position > 0 && position <= 2) {
1026+
return 0;
1027+
}
1028+
1029+
return position;
1030+
}
1031+
1032+
function rememberZNCLBL01LMTerminalTargetPosition(
1033+
position: number,
1034+
model: Definition,
1035+
endpoint: Zh.Endpoint | Zh.Group,
1036+
clearNonTerminal: boolean,
1037+
): void {
1038+
if (!["ZNCLBL01LM"].includes(model.model)) {
1039+
return;
1040+
}
1041+
1042+
if (position === 0 || position === 100) {
1043+
globalStore.clearValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY);
1044+
globalStore.putValue(endpoint, ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY, position);
1045+
} else if (clearNonTerminal) {
1046+
globalStore.clearValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY);
1047+
globalStore.clearValue(endpoint, ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY);
1048+
}
1049+
}
1050+
9981051
const numericAttributes2Lookup = (model: Definition, dataObject: KeyValue) => {
9991052
let result: KeyValue = {};
10001053
for (const [key, value] of Object.entries(dataObject)) {
@@ -6698,7 +6751,7 @@ export const fromZigbee = {
66986751
const invert = model.meta?.coverInverted ? !options.invert_cover : options.invert_cover;
66996752
if (msg.data.currentPositionLiftPercentage !== undefined && msg.data.currentPositionLiftPercentage <= 100) {
67006753
const value = msg.data.currentPositionLiftPercentage;
6701-
const position = invert ? 100 - value : value;
6754+
const position = normalizeZNCLBL01LMTerminalPosition(invert ? 100 - value : value, model, msg.endpoint);
67026755
const state = invert ? (position > 0 ? "CLOSE" : "OPEN") : position > 0 ? "OPEN" : "CLOSE";
67036756
result[postfixWithEndpointName("position", msg, model, meta)] = position;
67046757
result[postfixWithEndpointName("state", msg, model, meta)] = state;
@@ -8859,6 +8912,10 @@ export const toZigbee = {
88598912
value = getFromLookup(value, lookup);
88608913
}
88618914
assertNumber(value);
8915+
const targetPosition = value;
8916+
if (["ZNCLBL01LM"].includes(meta.mapped.model)) {
8917+
rememberZNCLBL01LMTerminalTargetPosition(targetPosition, meta.mapped, entity, true);
8918+
}
88628919
value = meta.options.invert_cover ? 100 - value : value;
88638920

88648921
if (["ZNCLBL01LM"].includes(meta.mapped.model)) {

test/lumi.test.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,229 @@ describe("lib/lumi", () => {
269269
expect(lateAttr107Payload).toStrictEqual({});
270270
});
271271

272+
it.each([
273+
{invert_cover: false, terminalReadbackPosition: 100, adjacentReadbackPosition: 99, expectedPayload: {position: 100, state: "OPEN"}},
274+
{invert_cover: false, terminalReadbackPosition: 100, adjacentReadbackPosition: 98, expectedPayload: {position: 100, state: "OPEN"}},
275+
{invert_cover: false, terminalReadbackPosition: 0, adjacentReadbackPosition: 1, expectedPayload: {position: 0, state: "CLOSE"}},
276+
{invert_cover: false, terminalReadbackPosition: 0, adjacentReadbackPosition: 2, expectedPayload: {position: 0, state: "CLOSE"}},
277+
{invert_cover: true, terminalReadbackPosition: 0, adjacentReadbackPosition: 1, expectedPayload: {position: 100, state: "CLOSE"}},
278+
{invert_cover: true, terminalReadbackPosition: 0, adjacentReadbackPosition: 2, expectedPayload: {position: 100, state: "CLOSE"}},
279+
{invert_cover: true, terminalReadbackPosition: 100, adjacentReadbackPosition: 99, expectedPayload: {position: 0, state: "OPEN"}},
280+
{invert_cover: true, terminalReadbackPosition: 100, adjacentReadbackPosition: 98, expectedPayload: {position: 0, state: "OPEN"}},
281+
])("normalizes adjacent terminal readback while stopped when invert_cover=$invert_cover", async ({
282+
invert_cover,
283+
terminalReadbackPosition,
284+
adjacentReadbackPosition,
285+
expectedPayload,
286+
}) => {
287+
const {device, msg} = createCurtainMessage();
288+
289+
await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: 2});
290+
291+
fromZigbee.lumi_curtain_position_tilt.convert(
292+
znclbl01lmDefinition,
293+
{
294+
data: {currentPositionLiftPercentage: terminalReadbackPosition},
295+
endpoint: device.endpoints[0],
296+
} as Fz.Message<"closuresWindowCovering", undefined, "readResponse">,
297+
null,
298+
{invert_cover},
299+
{} as Fz.Meta,
300+
);
301+
302+
const adjacentReadbackPayload = fromZigbee.lumi_curtain_position_tilt.convert(
303+
znclbl01lmDefinition,
304+
{
305+
data: {currentPositionLiftPercentage: adjacentReadbackPosition},
306+
endpoint: device.endpoints[0],
307+
} as Fz.Message<"closuresWindowCovering", undefined, "readResponse">,
308+
null,
309+
{invert_cover},
310+
{} as Fz.Meta,
311+
);
312+
313+
expect(adjacentReadbackPayload).toStrictEqual(expectedPayload);
314+
});
315+
316+
it.each([
317+
{invert_cover: false, adjacentReadbackPosition: 99, expectedPayload: {position: 99, state: "OPEN"}},
318+
{invert_cover: false, adjacentReadbackPosition: 98, expectedPayload: {position: 98, state: "OPEN"}},
319+
{invert_cover: false, adjacentReadbackPosition: 1, expectedPayload: {position: 1, state: "OPEN"}},
320+
{invert_cover: false, adjacentReadbackPosition: 2, expectedPayload: {position: 2, state: "OPEN"}},
321+
{invert_cover: true, adjacentReadbackPosition: 1, expectedPayload: {position: 99, state: "CLOSE"}},
322+
{invert_cover: true, adjacentReadbackPosition: 2, expectedPayload: {position: 98, state: "CLOSE"}},
323+
{invert_cover: true, adjacentReadbackPosition: 99, expectedPayload: {position: 1, state: "CLOSE"}},
324+
{invert_cover: true, adjacentReadbackPosition: 98, expectedPayload: {position: 2, state: "CLOSE"}},
325+
])("keeps adjacent readback unchanged without a previous terminal endpoint when invert_cover=$invert_cover", async ({
326+
invert_cover,
327+
adjacentReadbackPosition,
328+
expectedPayload,
329+
}) => {
330+
const {device, msg} = createCurtainMessage();
331+
332+
await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: 2});
333+
334+
const adjacentReadbackPayload = fromZigbee.lumi_curtain_position_tilt.convert(
335+
znclbl01lmDefinition,
336+
{
337+
data: {currentPositionLiftPercentage: adjacentReadbackPosition},
338+
endpoint: device.endpoints[0],
339+
} as Fz.Message<"closuresWindowCovering", undefined, "readResponse">,
340+
null,
341+
{invert_cover},
342+
{} as Fz.Meta,
343+
);
344+
345+
expect(adjacentReadbackPayload).toStrictEqual(expectedPayload);
346+
});
347+
348+
it.each([
349+
{invert_cover: false, terminalTargetPosition: 100, adjacentTargetPosition: 99, expectedTargetPosition: 100},
350+
{invert_cover: false, terminalTargetPosition: 100, adjacentTargetPosition: 98, expectedTargetPosition: 100},
351+
{invert_cover: false, terminalTargetPosition: 0, adjacentTargetPosition: 1, expectedTargetPosition: 0},
352+
{invert_cover: false, terminalTargetPosition: 0, adjacentTargetPosition: 2, expectedTargetPosition: 0},
353+
{invert_cover: true, terminalTargetPosition: 0, adjacentTargetPosition: 1, expectedTargetPosition: 100},
354+
{invert_cover: true, terminalTargetPosition: 0, adjacentTargetPosition: 2, expectedTargetPosition: 100},
355+
{invert_cover: true, terminalTargetPosition: 100, adjacentTargetPosition: 99, expectedTargetPosition: 0},
356+
{invert_cover: true, terminalTargetPosition: 100, adjacentTargetPosition: 98, expectedTargetPosition: 0},
357+
])("normalizes adjacent target_position while stopped when invert_cover=$invert_cover", async ({
358+
invert_cover,
359+
terminalTargetPosition,
360+
adjacentTargetPosition,
361+
expectedTargetPosition,
362+
}) => {
363+
const {msg} = createCurtainMessage();
364+
365+
await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: 2});
366+
367+
await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1055: terminalTargetPosition});
368+
369+
const adjacentTargetPayload = await numericAttributes2Payload(
370+
msg,
371+
{} as Fz.Meta,
372+
znclbl01lmDefinition,
373+
{invert_cover},
374+
{1055: adjacentTargetPosition},
375+
);
376+
377+
expect(adjacentTargetPayload).toStrictEqual({target_position: expectedTargetPosition});
378+
});
379+
380+
it.each([
381+
{
382+
invert_cover: false,
383+
terminalTargetPosition: 100,
384+
runningStateValue: 1,
385+
adjacentPosition: 98,
386+
expectedPayload: {position: 100, state: "OPEN"},
387+
},
388+
{
389+
invert_cover: false,
390+
terminalTargetPosition: 0,
391+
runningStateValue: 0,
392+
adjacentPosition: 2,
393+
expectedPayload: {position: 0, state: "CLOSE"},
394+
},
395+
{
396+
invert_cover: true,
397+
terminalTargetPosition: 0,
398+
runningStateValue: 0,
399+
adjacentPosition: 2,
400+
expectedPayload: {position: 100, state: "CLOSE"},
401+
},
402+
{
403+
invert_cover: true,
404+
terminalTargetPosition: 100,
405+
runningStateValue: 1,
406+
adjacentPosition: 98,
407+
expectedPayload: {position: 0, state: "OPEN"},
408+
},
409+
])("keeps terminal target through movement for stopped readback when invert_cover=$invert_cover", async ({
410+
invert_cover,
411+
terminalTargetPosition,
412+
runningStateValue,
413+
adjacentPosition,
414+
expectedPayload,
415+
}) => {
416+
const {device, msg} = createCurtainMessage();
417+
418+
await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1055: terminalTargetPosition});
419+
await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: runningStateValue});
420+
await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: 2});
421+
422+
const adjacentReadbackPayload = fromZigbee.lumi_curtain_position_tilt.convert(
423+
znclbl01lmDefinition,
424+
{
425+
data: {currentPositionLiftPercentage: adjacentPosition},
426+
endpoint: device.endpoints[0],
427+
} as Fz.Message<"closuresWindowCovering", undefined, "readResponse">,
428+
null,
429+
{invert_cover},
430+
{} as Fz.Meta,
431+
);
432+
433+
expect(adjacentReadbackPayload).toStrictEqual(expectedPayload);
434+
});
435+
436+
it.each([
437+
{invert_cover: false, terminalReadbackPosition: 0, requestedPosition: 1, reportedPosition: 1, expectedState: "OPEN"},
438+
{invert_cover: false, terminalReadbackPosition: 0, requestedPosition: 2, reportedPosition: 2, expectedState: "OPEN"},
439+
{invert_cover: false, terminalReadbackPosition: 100, requestedPosition: 98, reportedPosition: 98, expectedState: "OPEN"},
440+
{invert_cover: false, terminalReadbackPosition: 100, requestedPosition: 99, reportedPosition: 99, expectedState: "OPEN"},
441+
{invert_cover: false, terminalReadbackPosition: 100, requestedPosition: 1, reportedPosition: 1, expectedState: "OPEN"},
442+
{invert_cover: false, terminalReadbackPosition: 100, requestedPosition: 2, reportedPosition: 2, expectedState: "OPEN"},
443+
{invert_cover: true, terminalReadbackPosition: 100, requestedPosition: 1, reportedPosition: 99, expectedState: "CLOSE"},
444+
{invert_cover: true, terminalReadbackPosition: 100, requestedPosition: 2, reportedPosition: 98, expectedState: "CLOSE"},
445+
{invert_cover: true, terminalReadbackPosition: 0, requestedPosition: 98, reportedPosition: 2, expectedState: "CLOSE"},
446+
{invert_cover: true, terminalReadbackPosition: 0, requestedPosition: 99, reportedPosition: 1, expectedState: "CLOSE"},
447+
{invert_cover: true, terminalReadbackPosition: 0, requestedPosition: 1, reportedPosition: 99, expectedState: "CLOSE"},
448+
{invert_cover: true, terminalReadbackPosition: 0, requestedPosition: 2, reportedPosition: 98, expectedState: "CLOSE"},
449+
])("keeps explicit non-terminal near-end target $requestedPosition when invert_cover=$invert_cover", async ({
450+
invert_cover,
451+
terminalReadbackPosition,
452+
requestedPosition,
453+
reportedPosition,
454+
expectedState,
455+
}) => {
456+
const {device, msg} = createCurtainMessage();
457+
const meta = {
458+
device,
459+
mapped: znclbl01lmDefinition,
460+
options: {invert_cover},
461+
} as Tz.Meta;
462+
463+
await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: 2});
464+
465+
fromZigbee.lumi_curtain_position_tilt.convert(
466+
znclbl01lmDefinition,
467+
{
468+
data: {currentPositionLiftPercentage: terminalReadbackPosition},
469+
endpoint: device.endpoints[0],
470+
} as Fz.Message<"closuresWindowCovering", undefined, "readResponse">,
471+
null,
472+
{invert_cover},
473+
{} as Fz.Meta,
474+
);
475+
476+
await toZigbee.lumi_curtain_position_state.convertSet(device.endpoints[0], "position", requestedPosition, meta);
477+
478+
const targetPayload = await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1055: reportedPosition});
479+
480+
const readbackPayload = fromZigbee.lumi_curtain_position_tilt.convert(
481+
znclbl01lmDefinition,
482+
{
483+
data: {currentPositionLiftPercentage: reportedPosition},
484+
endpoint: device.endpoints[0],
485+
} as Fz.Message<"closuresWindowCovering", undefined, "readResponse">,
486+
null,
487+
{invert_cover},
488+
{} as Fz.Meta,
489+
);
490+
491+
expect(targetPayload).toStrictEqual({target_position: requestedPosition});
492+
expect(readbackPayload).toStrictEqual({position: requestedPosition, state: expectedState});
493+
});
494+
272495
it.each([
273496
{invert_cover: false, runningStateValue: 1, resumedAttr107Position: 99, expectedPosition: 99, expectedState: "OPEN"},
274497
{invert_cover: true, runningStateValue: 0, resumedAttr107Position: 1, expectedPosition: 99, expectedState: "CLOSE"},

0 commit comments

Comments
 (0)