From b8b5f82622f4add3697ba7545b79138f26bd1d82 Mon Sep 17 00:00:00 2001 From: Maximilian Rink Date: Sun, 12 Jul 2026 02:55:50 +0200 Subject: [PATCH] feat(sonoff): retain SWV irrigation plan slots --- src/devices/sonoff.ts | 293 +++++++++++++++++++++++------------------- test/sonoff.test.ts | 77 ++++++++++- 2 files changed, 234 insertions(+), 136 deletions(-) diff --git a/src/devices/sonoff.ts b/src/devices/sonoff.ts index 2508d2fabe409..5a0212d803922 100644 --- a/src/devices/sonoff.ts +++ b/src/devices/sonoff.ts @@ -22,6 +22,7 @@ import { YEAR_2000_IN_UTC, zclArrayValueToBytes, } from "../lib/sonoff"; +import * as globalStore from "../lib/store"; import * as tuya from "../lib/tuya"; import type {Configure, DefinitionWithExtend, Expose, Fz, KeyValue, KeyValueAny, ModernExtend, OnEvent, Tz, Zh} from "../lib/types"; import * as utils from "../lib/utils"; @@ -4275,50 +4276,146 @@ const sonoffExtend = { e.text("create_datetime", ea.SET).withDescription("Create datetime in ISO format with timezone (e.g. YYYY-MM-DDTHH:mm:ss+08:00)"), ); - const irrigationPlanReport = e - .composite("irrigation_plan_report", "irrigation_plan_report", ea.STATE) - .withDescription("Irrigation plan report") - .withFeature(e.numeric("plan_index", ea.STATE)) - .withFeature(e.binary("enable_state", ea.STATE, true, false)) - .withFeature(e.enum("loop_type_mode", ea.STATE, ["odd_days", "even_days", "day_interval", "weekdays"])) - .withFeature(e.numeric("loop_type_interval_days", ea.STATE).withDescription("Effective when loop_type_mode is day_interval")) - .withFeature( - e - .composite("loop_type_week_days", "loop_type_week_days", ea.STATE) - .withDescription("Effective when loop_type_mode is weekdays") - .withFeature(e.binary("sunday", ea.STATE, true, false)) - .withFeature(e.binary("monday", ea.STATE, true, false)) - .withFeature(e.binary("tuesday", ea.STATE, true, false)) - .withFeature(e.binary("wednesday", ea.STATE, true, false)) - .withFeature(e.binary("thursday", ea.STATE, true, false)) - .withFeature(e.binary("friday", ea.STATE, true, false)) - .withFeature(e.binary("saturday", ea.STATE, true, false)), - ) - .withFeature(e.text("enable_date", ea.STATE).withDescription("Enable date in local YYYY-MM-DD format (local day start)")) - .withFeature(e.text("start_time", ea.STATE).withDescription("Start time in local HH:mm format (24-hour)")) - .withFeature( - e.enum( - "irrigation_mode", - ea.STATE, - hasFlowMeter ? ["duration", "capacity", "duration_with_interval"] : ["duration", "duration_with_interval"], - ), - ) - .withFeature(e.numeric("irrigation_total_duration", ea.STATE)) - .withFeature(e.numeric("irrigation_duration", ea.STATE)) - .withFeature(e.numeric("interval_duration", ea.STATE)); - if (hasFlowMeter) { - irrigationPlanReport - .withFeature(e.enum("irrigation_amount_unit", ea.STATE, ["US gallon", "liter"])) - .withFeature(e.numeric("irrigation_amount", ea.STATE)) - .withFeature(e.numeric("fail_safe", ea.STATE)); - } - irrigationPlanReport.withFeature( - e.text("create_datetime", ea.STATE).withDescription("Create datetime in ISO format with timezone (e.g. YYYY-MM-DDTHH:mm:ss+08:00)"), + const createIrrigationPlanReportExpose = (name: string, description: string): exposes.Composite => { + const expose = e + .composite(name, name, ea.STATE) + .withDescription(description) + .withFeature(e.numeric("plan_index", ea.STATE)) + .withFeature(e.binary("enable_state", ea.STATE, true, false)) + .withFeature(e.enum("loop_type_mode", ea.STATE, ["odd_days", "even_days", "day_interval", "weekdays"])) + .withFeature(e.numeric("loop_type_interval_days", ea.STATE).withDescription("Effective when loop_type_mode is day_interval")) + .withFeature( + e + .composite("loop_type_week_days", "loop_type_week_days", ea.STATE) + .withDescription("Effective when loop_type_mode is weekdays") + .withFeature(e.binary("sunday", ea.STATE, true, false)) + .withFeature(e.binary("monday", ea.STATE, true, false)) + .withFeature(e.binary("tuesday", ea.STATE, true, false)) + .withFeature(e.binary("wednesday", ea.STATE, true, false)) + .withFeature(e.binary("thursday", ea.STATE, true, false)) + .withFeature(e.binary("friday", ea.STATE, true, false)) + .withFeature(e.binary("saturday", ea.STATE, true, false)), + ) + .withFeature(e.text("enable_date", ea.STATE).withDescription("Enable date in local YYYY-MM-DD format (local day start)")) + .withFeature(e.text("start_time", ea.STATE).withDescription("Start time in local HH:mm format (24-hour)")) + .withFeature( + e.enum( + "irrigation_mode", + ea.STATE, + hasFlowMeter ? ["duration", "capacity", "duration_with_interval"] : ["duration", "duration_with_interval"], + ), + ) + .withFeature(e.numeric("irrigation_total_duration", ea.STATE)) + .withFeature(e.numeric("irrigation_duration", ea.STATE)) + .withFeature(e.numeric("interval_duration", ea.STATE)); + if (hasFlowMeter) { + expose + .withFeature(e.enum("irrigation_amount_unit", ea.STATE, ["US gallon", "liter"])) + .withFeature(e.numeric("irrigation_amount", ea.STATE)) + .withFeature(e.numeric("fail_safe", ea.STATE)); + } + return expose.withFeature( + e.text("create_datetime", ea.STATE).withDescription("Create datetime in ISO format with timezone (e.g. YYYY-MM-DDTHH:mm:ss+08:00)"), + ); + }; + + const irrigationPlanReport = createIrrigationPlanReportExpose( + "irrigation_plan_report", + "Last irrigation plan report received from the device.", + ); + const irrigationPlanSlotReports = Array.from({length: 6}, (_, planIndex) => + createIrrigationPlanReportExpose(`irrigation_plan_report_${planIndex}`, `Cached irrigation plan report for plan_index ${planIndex}.`), ); - const baseExposes = [irrigationPlanSettings, irrigationPlanReport]; + // The public protocol only pushes one irrigationPlanReport object at a time; no confirmed safe per-slot read command is known yet. + const baseExposes = [irrigationPlanSettings, irrigationPlanReport, ...irrigationPlanSlotReports]; const allExposes = baseExposes.flatMap((expose) => exposeCompositeEndpoints(expose, endpointNames)); + const decodeIrrigationPlanReport = (payload: Buffer): KeyValueAny | undefined => { + if (payload.length < 28) return; + + let offset = 0; + + const planIndex = payload.readUInt8(offset); + offset += 1; + if (planIndex > 5) { + logger.error(`irrigationPlanReport invalid plan_index=${planIndex}`, NS); + return; + } + + const enableState = payload.readUInt8(offset); + offset += 1; + + const loopType = payload.readUInt16BE(offset); + const loopTypeMode = (loopType >> 8) & 0xff; + const loopTypeValue = loopType & 0xff; + offset += 2; + + const enableDatetimeDevice = payload.readUInt32BE(offset); + const offsetSeconds = getRuntimeLocalOffsetSeconds(enableDatetimeDevice + YEAR_2000_IN_UTC); + const enableDatetimeUTC = deviceLocal2000ToUTCSeconds(enableDatetimeDevice, offsetSeconds); + offset += 4; + + const irrigationMode = payload.readUInt8(offset); + offset += 1; + + const startSeconds = payload.readUInt32BE(offset); + const enableDateISO = formatUtcSecondsToIsoWithOffset(enableDatetimeUTC, offsetSeconds); + const enableDate = enableDateISO.slice(0, 10); + const startHours = Math.floor(startSeconds / 3600); + const startMinutes = Math.floor((startSeconds % 3600) / 60); + const startTime = `${String(startHours).padStart(2, "0")}:${String(startMinutes).padStart(2, "0")}`; + offset += 4; + + const irrigationTotalDuration = payload.readUInt16BE(offset); + offset += 2; + + const irrigationDuration = payload.readUInt16BE(offset); + offset += 2; + + const intervalDuration = payload.readUInt16BE(offset); + offset += 2; + + const irrigationAmountUnit = payload.readUInt8(offset); + offset += 1; + + const irrigationAmount = payload.readUInt16BE(offset); + offset += 2; + + const failSafe = payload.readUInt16BE(offset); + offset += 2; + + const createDatetimeDevice = payload.readUInt32BE(offset); + const createDatetimeISO = formatUtcSecondsToIsoWithOffset(createDatetimeDevice, offsetSeconds); + + return { + plan_index: planIndex, + enable_state: enableState === 1, + loop_type_mode: loopTypeModeMapping[loopTypeMode], + loop_type_interval_days: loopTypeMode === loopTypeModeMappingReverse.day_interval ? loopTypeValue : 0, + loop_type_week_days: + loopTypeMode === loopTypeModeMappingReverse.weekdays ? decodeLoopTypeWeekDays(loopTypeValue) : decodeLoopTypeWeekDays(0), + enable_date: enableDate, + start_time: startTime, + irrigation_mode: hasFlowMeter + ? (irrigationModeMapping[irrigationMode] ?? "duration") + : irrigationModeMapping[irrigationMode] === "duration_with_interval" + ? "duration_with_interval" + : "duration", + irrigation_total_duration: irrigationTotalDuration, + irrigation_duration: irrigationDuration, + interval_duration: intervalDuration, + ...(hasFlowMeter + ? { + irrigation_amount_unit: irrigationAmountUnitMapping[irrigationAmountUnit], + irrigation_amount: irrigationAmount, + fail_safe: failSafe, + } + : {}), + create_datetime: createDatetimeISO, + }; + }; + const fromZigbee: Fz.Converter<"customClusterEwelink", SonoffSwvzn, ["raw"]>[] = [ { cluster: clusterName, @@ -4349,102 +4446,28 @@ const sonoffExtend = { } if (cmdId === commandId.irrigationPlanReport) { - if (payload.length < 28) return; + const report = decodeIrrigationPlanReport(payload); + if (!report) return; const property = utils.postfixWithEndpointName("irrigation_plan_report", msg, model, meta); - - // Payload byte offset - let offset = 0; - - // Plan index - const planIndex = payload.readUInt8(offset); - offset += 1; - - // Whether schedule is enabled - const enableState = payload.readUInt8(offset); - offset += 1; - - // Loop type - const loopType = payload.readUInt16BE(offset); - const loopTypeMode = (loopType >> 8) & 0xff; // Loop mode - const loopTypeValue = loopType & 0xff; // Loop configuration - offset += 2; - - // Enable date(day start): device 2000-local seconds -> Unix UTC seconds - const enableDatetimeDevice = payload.readUInt32BE(offset); - const offsetSeconds = getRuntimeLocalOffsetSeconds(enableDatetimeDevice + YEAR_2000_IN_UTC); - const enableDatetimeUTC = deviceLocal2000ToUTCSeconds(enableDatetimeDevice, offsetSeconds); - offset += 4; - - // Irrigation mode - const irrigationMode = payload.readUInt8(offset); - offset += 1; - - // Effective start time (seconds from 00:00) - const startSeconds = payload.readUInt32BE(offset); - const enableDateISO = formatUtcSecondsToIsoWithOffset(enableDatetimeUTC, offsetSeconds); - const enableDate = enableDateISO.slice(0, 10); - const startHours = Math.floor(startSeconds / 3600); - const startMinutes = Math.floor((startSeconds % 3600) / 60); - const startTime = `${String(startHours).padStart(2, "0")}:${String(startMinutes).padStart(2, "0")}`; - offset += 4; - - // Irrigation duration - const irrigationTotalDuration = payload.readUInt16BE(offset); - offset += 2; - - const irrigationDuration = payload.readUInt16BE(offset); - offset += 2; - - // Interval duration - const intervalDuration = payload.readUInt16BE(offset); - offset += 2; - - // Irrigation amount unit - const irrigationAmountUnit = payload.readUInt8(offset); - offset += 1; - - // Irrigation amount - const irrigationAmount = payload.readUInt16BE(offset); - offset += 2; - - // Fail-safe timeout - const failSafe = payload.readUInt16BE(offset); - offset += 2; - - // Create datetime: Unix UTC seconds - const createDatetimeDevice = payload.readUInt32BE(offset); - const createDatetimeISO = formatUtcSecondsToIsoWithOffset(createDatetimeDevice, offsetSeconds); - - return { - [property]: { - plan_index: planIndex, - enable_state: enableState === 1, - loop_type_mode: loopTypeModeMapping[loopTypeMode], - loop_type_interval_days: loopTypeMode === loopTypeModeMappingReverse.day_interval ? loopTypeValue : 0, - loop_type_week_days: - loopTypeMode === loopTypeModeMappingReverse.weekdays - ? decodeLoopTypeWeekDays(loopTypeValue) - : decodeLoopTypeWeekDays(0), - enable_date: enableDate, - start_time: startTime, - irrigation_mode: hasFlowMeter - ? (irrigationModeMapping[irrigationMode] ?? "duration") - : irrigationModeMapping[irrigationMode] === "duration_with_interval" - ? "duration_with_interval" - : "duration", - irrigation_total_duration: irrigationTotalDuration, - irrigation_duration: irrigationDuration, - interval_duration: intervalDuration, - ...(hasFlowMeter - ? { - irrigation_amount_unit: irrigationAmountUnitMapping[irrigationAmountUnit], - irrigation_amount: irrigationAmount, - fail_safe: failSafe, - } - : {}), - create_datetime: createDatetimeISO, - }, - }; + const endpointName = endpointNames ? utils.getKey(model.endpoint?.(meta.device) ?? {}, msg.endpoint.ID) : undefined; + const storeKey = `irrigation_plan_reports_${endpointName ?? "default"}`; + const storeEntity = meta.device ?? msg.device ?? msg.endpoint; + const result: KeyValueAny = {[property]: report}; + if (!storeEntity) { + result[utils.postfixWithEndpointName(`irrigation_plan_report_${report.plan_index}`, msg, model, meta)] = report; + return result; + } + const reportsByIndex = utils.isObject(globalStore.getValue(storeEntity, storeKey, {})) + ? {...globalStore.getValue(storeEntity, storeKey, {})} + : {}; + reportsByIndex[report.plan_index] = report; + globalStore.putValue(storeEntity, storeKey, reportsByIndex); + + // The device reports one slot object at a time. Keep a per-index cache so HA can read slots independently after iterating 0-5. + for (const [planIndex, planIndexReport] of Object.entries(reportsByIndex)) { + result[utils.postfixWithEndpointName(`irrigation_plan_report_${planIndex}`, msg, model, meta)] = planIndexReport; + } + return result; } }, }, diff --git a/test/sonoff.test.ts b/test/sonoff.test.ts index eec2cdd946441..536fa76f18be3 100644 --- a/test/sonoff.test.ts +++ b/test/sonoff.test.ts @@ -2,6 +2,7 @@ import type {Mock} from "vitest"; import {beforeEach, describe, expect, it, vi} from "vitest"; import type {Models as ZHModels} from "zigbee-herdsman"; import {findByDevice} from "../src/index"; +import * as globalStore from "../src/lib/store"; import type {Definition, Fz, Tz} from "../src/lib/types"; import {mockDevice} from "./utils"; @@ -634,13 +635,17 @@ describe("Sonoff SNZB-02DR2", () => { describe("Sonoff SWV-ZFE", () => { let device: Definition; + let zigbeeDevice: ZHModels.Device; let endpoint: ZHModels.Endpoint; let writeFn: Mock; let commandFn: Mock; let meta: Tz.Meta; + let fzMeta: Fz.Meta; beforeEach(async () => { - device = await findByDevice(mockDevice({modelID: "SWV-ZFE", endpoints: [{ID: 1}]})); + globalStore.clear(); + zigbeeDevice = mockDevice({modelID: "SWV-ZFE", endpoints: [{ID: 1}]}); + device = await findByDevice(zigbeeDevice); writeFn = vi.fn(); commandFn = vi.fn(); @@ -708,6 +713,76 @@ describe("Sonoff SWV-ZFE", () => { publish: null, endpoint_name: null, }; + fzMeta = { + state: {}, + device: zigbeeDevice, + deviceExposesChanged: null, + }; + }); + + const makeIrrigationPlanReportFrame = (planIndex: number, startMinutes: number, irrigationDuration: number): Buffer => { + const payload = Buffer.alloc(28); + let offset = 0; + payload.writeUInt8(planIndex, offset++); + payload.writeUInt8(1, offset++); + payload.writeUInt16BE(0x0305, offset); + offset += 2; + payload.writeUInt32BE(835574400, offset); + offset += 4; + payload.writeUInt8(0, offset++); + payload.writeUInt32BE(startMinutes * 60, offset); + offset += 4; + payload.writeUInt16BE(20, offset); + offset += 2; + payload.writeUInt16BE(irrigationDuration, offset); + offset += 2; + payload.writeUInt16BE(3, offset); + offset += 2; + payload.writeUInt8(1, offset++); + payload.writeUInt16BE(50, offset); + offset += 2; + payload.writeUInt16BE(30, offset); + offset += 2; + payload.writeUInt32BE(1782023400, offset); + + return Buffer.concat([Buffer.from([0x18, 0x00, 0x09]), payload]); + }; + + const convertRawIrrigationPlanReport = (frame: Buffer) => { + const msg: Fz.Message<"customClusterEwelink", undefined, "raw"> = { + data: frame, + endpoint: {ID: 1} as ZHModels.Endpoint, + device: zigbeeDevice, + meta: {}, + groupID: null, + type: "raw", + cluster: "customClusterEwelink", + linkquality: 0, + }; + + for (const converter of device.fromZigbee.filter( + (candidate) => candidate.cluster === "customClusterEwelink" && candidate.type.includes("raw"), + )) { + const result = converter.convert(device, msg, null, null, fzMeta); + if (result) return result; + } + }; + + describe("fromZigbee", () => { + it("retains irrigation plan reports by plan index", () => { + const first = convertRawIrrigationPlanReport(makeIrrigationPlanReportFrame(0, 6 * 60 + 30, 9)); + const second = convertRawIrrigationPlanReport(makeIrrigationPlanReportFrame(2, 9 * 60 + 20, 2)); + + expect(first).toMatchObject({ + irrigation_plan_report: {plan_index: 0, start_time: "06:30", irrigation_duration: 9}, + irrigation_plan_report_0: {plan_index: 0, start_time: "06:30", irrigation_duration: 9}, + }); + expect(second).toMatchObject({ + irrigation_plan_report: {plan_index: 2, start_time: "09:20", irrigation_duration: 2}, + irrigation_plan_report_0: {plan_index: 0, start_time: "06:30", irrigation_duration: 9}, + irrigation_plan_report_2: {plan_index: 2, start_time: "09:20", irrigation_duration: 2}, + }); + }); }); describe("toZigbee", () => {