diff --git a/src/lib/lumi.ts b/src/lib/lumi.ts index f7870bd2a294d..fefc2b1a1d115 100644 --- a/src/lib/lumi.ts +++ b/src/lib/lumi.ts @@ -47,6 +47,8 @@ const NS = "zhc:lumi"; const e = exposes.presets; const ea = exposes.access; const ZNCLBL01LM_RUNNING_STORE_KEY = "ZNCLBL01LM_running"; +const ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY = "ZNCLBL01LM_terminal_position"; +const ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY = "ZNCLBL01LM_terminal_target_position"; declare type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; @@ -876,7 +878,9 @@ export const numericAttributes2Payload = async ( case "1055": if (["ZNCLBL01LM"].includes(model.model)) { assertNumber(value); - payload.target_position = options.invert_cover ? 100 - value : value; + const targetPosition = options.invert_cover ? 100 - value : value; + rememberZNCLBL01LMTerminalTargetPosition(targetPosition, model, msg.endpoint, false); + payload.target_position = normalizeZNCLBL01LMTerminalPosition(targetPosition, model, msg.endpoint); } break; case "1056": @@ -896,6 +900,9 @@ export const numericAttributes2Payload = async ( assertNumber(value); payload.running = value < 2; globalStore.putValue(msg.endpoint, ZNCLBL01LM_RUNNING_STORE_KEY, payload.running); + if (payload.running) { + globalStore.clearValue(msg.endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY); + } // https://github.com/Koenkk/zigbee-herdsman-converters/pull/11911 if (!payload.running && previousRunning !== false) { @@ -995,6 +1002,52 @@ export const numericAttributes2Payload = async ( return payload; }; +function normalizeZNCLBL01LMTerminalPosition(position: number, model: Definition, endpoint: Zh.Endpoint | Zh.Group): number { + if (!["ZNCLBL01LM"].includes(model.model)) { + return position; + } + + if (position === 0 || position === 100) { + globalStore.putValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY, position); + return position; + } + + if (globalStore.getValue(endpoint, ZNCLBL01LM_RUNNING_STORE_KEY, undefined) !== false) { + return position; + } + + const terminalPosition = + globalStore.getValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY, undefined) ?? + globalStore.getValue(endpoint, ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY, undefined); + if (terminalPosition === 100 && position >= 98 && position < 100) { + return 100; + } + if (terminalPosition === 0 && position > 0 && position <= 2) { + return 0; + } + + return position; +} + +function rememberZNCLBL01LMTerminalTargetPosition( + position: number, + model: Definition, + endpoint: Zh.Endpoint | Zh.Group, + clearNonTerminal: boolean, +): void { + if (!["ZNCLBL01LM"].includes(model.model)) { + return; + } + + if (position === 0 || position === 100) { + globalStore.clearValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY); + globalStore.putValue(endpoint, ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY, position); + } else if (clearNonTerminal) { + globalStore.clearValue(endpoint, ZNCLBL01LM_TERMINAL_POSITION_STORE_KEY); + globalStore.clearValue(endpoint, ZNCLBL01LM_TERMINAL_TARGET_POSITION_STORE_KEY); + } +} + const numericAttributes2Lookup = (model: Definition, dataObject: KeyValue) => { let result: KeyValue = {}; for (const [key, value] of Object.entries(dataObject)) { @@ -6698,7 +6751,7 @@ export const fromZigbee = { const invert = model.meta?.coverInverted ? !options.invert_cover : options.invert_cover; if (msg.data.currentPositionLiftPercentage !== undefined && msg.data.currentPositionLiftPercentage <= 100) { const value = msg.data.currentPositionLiftPercentage; - const position = invert ? 100 - value : value; + const position = normalizeZNCLBL01LMTerminalPosition(invert ? 100 - value : value, model, msg.endpoint); const state = invert ? (position > 0 ? "CLOSE" : "OPEN") : position > 0 ? "OPEN" : "CLOSE"; result[postfixWithEndpointName("position", msg, model, meta)] = position; result[postfixWithEndpointName("state", msg, model, meta)] = state; @@ -8859,6 +8912,10 @@ export const toZigbee = { value = getFromLookup(value, lookup); } assertNumber(value); + const targetPosition = value; + if (["ZNCLBL01LM"].includes(meta.mapped.model)) { + rememberZNCLBL01LMTerminalTargetPosition(targetPosition, meta.mapped, entity, true); + } value = meta.options.invert_cover ? 100 - value : value; if (["ZNCLBL01LM"].includes(meta.mapped.model)) { diff --git a/test/lumi.test.ts b/test/lumi.test.ts index 8966abf4c0189..47fefd2ee3e4d 100644 --- a/test/lumi.test.ts +++ b/test/lumi.test.ts @@ -269,6 +269,229 @@ describe("lib/lumi", () => { expect(lateAttr107Payload).toStrictEqual({}); }); + it.each([ + {invert_cover: false, terminalReadbackPosition: 100, adjacentReadbackPosition: 99, expectedPayload: {position: 100, state: "OPEN"}}, + {invert_cover: false, terminalReadbackPosition: 100, adjacentReadbackPosition: 98, expectedPayload: {position: 100, state: "OPEN"}}, + {invert_cover: false, terminalReadbackPosition: 0, adjacentReadbackPosition: 1, expectedPayload: {position: 0, state: "CLOSE"}}, + {invert_cover: false, terminalReadbackPosition: 0, adjacentReadbackPosition: 2, expectedPayload: {position: 0, state: "CLOSE"}}, + {invert_cover: true, terminalReadbackPosition: 0, adjacentReadbackPosition: 1, expectedPayload: {position: 100, state: "CLOSE"}}, + {invert_cover: true, terminalReadbackPosition: 0, adjacentReadbackPosition: 2, expectedPayload: {position: 100, state: "CLOSE"}}, + {invert_cover: true, terminalReadbackPosition: 100, adjacentReadbackPosition: 99, expectedPayload: {position: 0, state: "OPEN"}}, + {invert_cover: true, terminalReadbackPosition: 100, adjacentReadbackPosition: 98, expectedPayload: {position: 0, state: "OPEN"}}, + ])("normalizes adjacent terminal readback while stopped when invert_cover=$invert_cover", async ({ + invert_cover, + terminalReadbackPosition, + adjacentReadbackPosition, + expectedPayload, + }) => { + const {device, msg} = createCurtainMessage(); + + await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: 2}); + + fromZigbee.lumi_curtain_position_tilt.convert( + znclbl01lmDefinition, + { + data: {currentPositionLiftPercentage: terminalReadbackPosition}, + endpoint: device.endpoints[0], + } as Fz.Message<"closuresWindowCovering", undefined, "readResponse">, + null, + {invert_cover}, + {} as Fz.Meta, + ); + + const adjacentReadbackPayload = fromZigbee.lumi_curtain_position_tilt.convert( + znclbl01lmDefinition, + { + data: {currentPositionLiftPercentage: adjacentReadbackPosition}, + endpoint: device.endpoints[0], + } as Fz.Message<"closuresWindowCovering", undefined, "readResponse">, + null, + {invert_cover}, + {} as Fz.Meta, + ); + + expect(adjacentReadbackPayload).toStrictEqual(expectedPayload); + }); + + it.each([ + {invert_cover: false, adjacentReadbackPosition: 99, expectedPayload: {position: 99, state: "OPEN"}}, + {invert_cover: false, adjacentReadbackPosition: 98, expectedPayload: {position: 98, state: "OPEN"}}, + {invert_cover: false, adjacentReadbackPosition: 1, expectedPayload: {position: 1, state: "OPEN"}}, + {invert_cover: false, adjacentReadbackPosition: 2, expectedPayload: {position: 2, state: "OPEN"}}, + {invert_cover: true, adjacentReadbackPosition: 1, expectedPayload: {position: 99, state: "CLOSE"}}, + {invert_cover: true, adjacentReadbackPosition: 2, expectedPayload: {position: 98, state: "CLOSE"}}, + {invert_cover: true, adjacentReadbackPosition: 99, expectedPayload: {position: 1, state: "CLOSE"}}, + {invert_cover: true, adjacentReadbackPosition: 98, expectedPayload: {position: 2, state: "CLOSE"}}, + ])("keeps adjacent readback unchanged without a previous terminal endpoint when invert_cover=$invert_cover", async ({ + invert_cover, + adjacentReadbackPosition, + expectedPayload, + }) => { + const {device, msg} = createCurtainMessage(); + + await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: 2}); + + const adjacentReadbackPayload = fromZigbee.lumi_curtain_position_tilt.convert( + znclbl01lmDefinition, + { + data: {currentPositionLiftPercentage: adjacentReadbackPosition}, + endpoint: device.endpoints[0], + } as Fz.Message<"closuresWindowCovering", undefined, "readResponse">, + null, + {invert_cover}, + {} as Fz.Meta, + ); + + expect(adjacentReadbackPayload).toStrictEqual(expectedPayload); + }); + + it.each([ + {invert_cover: false, terminalTargetPosition: 100, adjacentTargetPosition: 99, expectedTargetPosition: 100}, + {invert_cover: false, terminalTargetPosition: 100, adjacentTargetPosition: 98, expectedTargetPosition: 100}, + {invert_cover: false, terminalTargetPosition: 0, adjacentTargetPosition: 1, expectedTargetPosition: 0}, + {invert_cover: false, terminalTargetPosition: 0, adjacentTargetPosition: 2, expectedTargetPosition: 0}, + {invert_cover: true, terminalTargetPosition: 0, adjacentTargetPosition: 1, expectedTargetPosition: 100}, + {invert_cover: true, terminalTargetPosition: 0, adjacentTargetPosition: 2, expectedTargetPosition: 100}, + {invert_cover: true, terminalTargetPosition: 100, adjacentTargetPosition: 99, expectedTargetPosition: 0}, + {invert_cover: true, terminalTargetPosition: 100, adjacentTargetPosition: 98, expectedTargetPosition: 0}, + ])("normalizes adjacent target_position while stopped when invert_cover=$invert_cover", async ({ + invert_cover, + terminalTargetPosition, + adjacentTargetPosition, + expectedTargetPosition, + }) => { + const {msg} = createCurtainMessage(); + + await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: 2}); + + await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1055: terminalTargetPosition}); + + const adjacentTargetPayload = await numericAttributes2Payload( + msg, + {} as Fz.Meta, + znclbl01lmDefinition, + {invert_cover}, + {1055: adjacentTargetPosition}, + ); + + expect(adjacentTargetPayload).toStrictEqual({target_position: expectedTargetPosition}); + }); + + it.each([ + { + invert_cover: false, + terminalTargetPosition: 100, + runningStateValue: 1, + adjacentPosition: 98, + expectedPayload: {position: 100, state: "OPEN"}, + }, + { + invert_cover: false, + terminalTargetPosition: 0, + runningStateValue: 0, + adjacentPosition: 2, + expectedPayload: {position: 0, state: "CLOSE"}, + }, + { + invert_cover: true, + terminalTargetPosition: 0, + runningStateValue: 0, + adjacentPosition: 2, + expectedPayload: {position: 100, state: "CLOSE"}, + }, + { + invert_cover: true, + terminalTargetPosition: 100, + runningStateValue: 1, + adjacentPosition: 98, + expectedPayload: {position: 0, state: "OPEN"}, + }, + ])("keeps terminal target through movement for stopped readback when invert_cover=$invert_cover", async ({ + invert_cover, + terminalTargetPosition, + runningStateValue, + adjacentPosition, + expectedPayload, + }) => { + const {device, msg} = createCurtainMessage(); + + await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1055: terminalTargetPosition}); + await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: runningStateValue}); + await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: 2}); + + const adjacentReadbackPayload = fromZigbee.lumi_curtain_position_tilt.convert( + znclbl01lmDefinition, + { + data: {currentPositionLiftPercentage: adjacentPosition}, + endpoint: device.endpoints[0], + } as Fz.Message<"closuresWindowCovering", undefined, "readResponse">, + null, + {invert_cover}, + {} as Fz.Meta, + ); + + expect(adjacentReadbackPayload).toStrictEqual(expectedPayload); + }); + + it.each([ + {invert_cover: false, terminalReadbackPosition: 0, requestedPosition: 1, reportedPosition: 1, expectedState: "OPEN"}, + {invert_cover: false, terminalReadbackPosition: 0, requestedPosition: 2, reportedPosition: 2, expectedState: "OPEN"}, + {invert_cover: false, terminalReadbackPosition: 100, requestedPosition: 98, reportedPosition: 98, expectedState: "OPEN"}, + {invert_cover: false, terminalReadbackPosition: 100, requestedPosition: 99, reportedPosition: 99, expectedState: "OPEN"}, + {invert_cover: false, terminalReadbackPosition: 100, requestedPosition: 1, reportedPosition: 1, expectedState: "OPEN"}, + {invert_cover: false, terminalReadbackPosition: 100, requestedPosition: 2, reportedPosition: 2, expectedState: "OPEN"}, + {invert_cover: true, terminalReadbackPosition: 100, requestedPosition: 1, reportedPosition: 99, expectedState: "CLOSE"}, + {invert_cover: true, terminalReadbackPosition: 100, requestedPosition: 2, reportedPosition: 98, expectedState: "CLOSE"}, + {invert_cover: true, terminalReadbackPosition: 0, requestedPosition: 98, reportedPosition: 2, expectedState: "CLOSE"}, + {invert_cover: true, terminalReadbackPosition: 0, requestedPosition: 99, reportedPosition: 1, expectedState: "CLOSE"}, + {invert_cover: true, terminalReadbackPosition: 0, requestedPosition: 1, reportedPosition: 99, expectedState: "CLOSE"}, + {invert_cover: true, terminalReadbackPosition: 0, requestedPosition: 2, reportedPosition: 98, expectedState: "CLOSE"}, + ])("keeps explicit non-terminal near-end target $requestedPosition when invert_cover=$invert_cover", async ({ + invert_cover, + terminalReadbackPosition, + requestedPosition, + reportedPosition, + expectedState, + }) => { + const {device, msg} = createCurtainMessage(); + const meta = { + device, + mapped: znclbl01lmDefinition, + options: {invert_cover}, + } as Tz.Meta; + + await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1057: 2}); + + fromZigbee.lumi_curtain_position_tilt.convert( + znclbl01lmDefinition, + { + data: {currentPositionLiftPercentage: terminalReadbackPosition}, + endpoint: device.endpoints[0], + } as Fz.Message<"closuresWindowCovering", undefined, "readResponse">, + null, + {invert_cover}, + {} as Fz.Meta, + ); + + await toZigbee.lumi_curtain_position_state.convertSet(device.endpoints[0], "position", requestedPosition, meta); + + const targetPayload = await numericAttributes2Payload(msg, {} as Fz.Meta, znclbl01lmDefinition, {invert_cover}, {1055: reportedPosition}); + + const readbackPayload = fromZigbee.lumi_curtain_position_tilt.convert( + znclbl01lmDefinition, + { + data: {currentPositionLiftPercentage: reportedPosition}, + endpoint: device.endpoints[0], + } as Fz.Message<"closuresWindowCovering", undefined, "readResponse">, + null, + {invert_cover}, + {} as Fz.Meta, + ); + + expect(targetPayload).toStrictEqual({target_position: requestedPosition}); + expect(readbackPayload).toStrictEqual({position: requestedPosition, state: expectedState}); + }); + it.each([ {invert_cover: false, runningStateValue: 1, resumedAttr107Position: 99, expectedPosition: 99, expectedState: "OPEN"}, {invert_cover: true, runningStateValue: 0, resumedAttr107Position: 1, expectedPosition: 99, expectedState: "CLOSE"},