diff --git a/biome.json b/biome.json index 45d130e1fc919..41330c41ece64 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.2/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/src/devices/futurehome.ts b/src/devices/futurehome.ts index 515720b4fe31b..42347665135ed 100644 --- a/src/devices/futurehome.ts +++ b/src/devices/futurehome.ts @@ -1,11 +1,32 @@ +import {Zcl} from "zigbee-herdsman"; import * as exposes from "../lib/exposes"; import * as m from "../lib/modernExtend"; import * as tuya from "../lib/tuya"; -import type {DefinitionWithExtend, Fz} from "../lib/types"; +import type {DefinitionWithExtend, Fz, KeyValue, ModernExtend, Tz} from "../lib/types"; const e = exposes.presets; const ea = exposes.access; +interface FuturehomeHaApplianceControl { + attributes: { + autoCharge: number; + energyMeterStart: number; + energyMeterNow: number; + chargingSessionStartT: number; + chargingSessionEndT: number; + a5: number; + a6: number; + a7: number; + a8: number; + status: number; + aa: number; + ab: number; + a10: number; + }; + commands: never; + commandResponses: never; +} + const localValueConverters = { energyMonotonic: { from: (value: number, meta: Fz.Meta) => { @@ -22,6 +43,195 @@ const localValueConverters = { }, }; +const futurehomeExtend = { + chargerStatus: (): ModernExtend => { + const extend: ModernExtend = { + isModernExtend: true, + fromZigbee: [ + { + cluster: "haApplianceControl", + type: ["commandSignalStateNotification", "commandSignalStateRsp"], + convert(model, msg, publish, options, meta) { + const status = msg?.data?.applianceStatus; + if (status === undefined || status === null) return; + let chargerStatus: + | "plugged_out" + | "1: Off" + | "4: plugged_in" + | "2: plugged_in_charging" + | "3: plugged_in_paused" + | "5: stopped" + | "8X: failure" = "plugged_out"; + let chargingOn = false; + + switch (status) { + case 0x01: // Off + chargerStatus = "1: Off"; + chargingOn = false; + break; + case 0x02: // StandBy → charging + chargerStatus = "2: plugged_in_charging"; + chargingOn = true; + break; + case 0x03: // Programmed (paused by user) + chargerStatus = "3: plugged_in_paused"; + chargingOn = false; + break; + case 0x04: // ProgrammedWaitingToStart + chargerStatus = "4: plugged_in"; + chargingOn = false; + break; + case 0x05: // Running + chargerStatus = "5: stopped"; + chargingOn = false; + break; + case 0x08: // Failure + chargerStatus = "8X: failure"; + chargingOn = false; + break; + default: + chargerStatus = "plugged_out"; + chargingOn = false; + } + return {charger_status: chargerStatus, charging_on: chargingOn === true ? "ON" : "OFF"}; + }, + } satisfies Fz.Converter<"haApplianceControl", undefined, ["commandSignalStateNotification", "commandSignalStateRsp"]>, + ], + toZigbee: [ + { + key: ["charger_status", "charging_on"], + convertGet: async (entity, key, meta) => { + await entity.command("haApplianceControl", "signalState", {}); + }, + } satisfies Tz.Converter, + ], + configure: [ + async (device, coordinatorEndpoint, logger) => { + for (const endpoint of device.endpoints) { + if (endpoint.supportsInputCluster("haApplianceControl")) { + await endpoint.bind("haApplianceControl", coordinatorEndpoint); + try { + await endpoint.command("haApplianceControl", "signalState", {}); + } catch { + // do nothing + } + } + } + }, + ], + exposes: [ + exposes + .enum("charger_status", ea.STATE_GET, [ + "plugged_out", + "1: Off", + "2: plugged_in_charging", + "3: plugged_in_paused", + "4: plugged_in", + "5: stopped", + "8X: failure", + ]) + .withDescription("Current EV charger state"), + exposes.binary("charging_on", ea.STATE, "ON", "OFF").withDescription("Indicates if the charger is actively delivering power"), + ], + }; + const pollExtend = m.poll({ + key: "charger_status_poll", + optionKey: "charger_status_poll_interval", + option: e + .numeric("charger_status_poll_interval", ea.SET) + .withValueMin(-1) + .withUnit("s") + .withDescription("Polling interval charger status (default: 60s, -1 to disable)"), + defaultIntervalSeconds: 60, + poll: async (device) => { + const endpoint = device.endpoints.find((e) => e.supportsInputCluster("haApplianceControl")); + if (endpoint) { + await endpoint.command("haApplianceControl", "signalState", {}); + } + }, + }); + extend.onEvent = pollExtend.onEvent; + extend.options = pollExtend.options; + return extend; + }, + charging: (): ModernExtend => { + const commandLookup: {[key: string]: number} = { + start: 0x01, + stop: 0x02, + pause: 0x03, + }; + return { + isModernExtend: true, + fromZigbee: [], + toZigbee: [ + { + key: ["charging_start", "charging_stop", "charging_pause"], + convertSet: async (entity, key, value, meta) => { + const normalizedAction = key.replace("charging_", ""); // "start", "stop", or "pause" + const commandId = commandLookup[normalizedAction]; + await entity.command("haApplianceControl", "executionOfCommand", {commandId: commandId}); + try { + await entity.command("haApplianceControl", "signalState", {}); + } catch { + // do nothing + } + return; // {state: {charging: value}}; + }, + convertGet: async (entity, key, meta) => { + await entity.command("haApplianceControl", "signalState", {}); + }, + } satisfies Tz.Converter, + ], + exposes: [ + exposes.enum("charging_start", ea.SET, ["start"]).withLabel("Start charging").withDescription("Press to start charging"), + exposes.enum("charging_stop", ea.SET, ["stop"]).withLabel("Stop charging").withDescription("Press to stop charging"), + exposes.enum("charging_pause", ea.SET, ["pause"]).withLabel("Pause charging").withDescription("Press to pause charging"), + ], + }; + }, + previousSessionEnergy: (): ModernExtend => { + return { + isModernExtend: true, + fromZigbee: [ + { + cluster: "haApplianceControl", + type: ["attributeReport", "readResponse"], + convert: (model, msg, publish, options, meta) => { + const result: KeyValue = {}; + let energyStart = meta.state?.energy_meter_start !== undefined ? meta.state.energy_meter_start : null; + let energyNow = meta.state?.energy_meter_now !== undefined ? meta.state.energy_meter_now : null; + let startT = meta.state?.start_t !== undefined ? meta.state.start_t : null; + let endT = meta.state?.end_t !== undefined ? meta.state.end_t : null; + if (msg.data.energyMeterStart !== undefined) { + energyStart = msg.data.energyMeterStart / 1000; + } + if (msg.data.energyMeterNow !== undefined) { + energyNow = msg.data.energyMeterNow / 1000; + } + if (msg.data.chargingSessionStartT !== undefined) { + startT = msg.data.chargingSessionStartT; + } + if (msg.data.chargingSessionEndT !== undefined) { + endT = msg.data.chargingSessionEndT; + } + if (energyStart !== null && energyNow !== null) { + result.session_energy = ((energyNow as number) - (energyStart as number)).toFixed(3); + } + if (startT !== null && endT !== null) { + result.charging_duration = (endT as number) - (startT as number); + } + return result; + }, + } satisfies Fz.Converter<"haApplianceControl", FuturehomeHaApplianceControl, ["attributeReport", "readResponse"]>, + ], + exposes: [ + exposes.numeric("session_energy", ea.STATE).withLabel("Session energy").withDescription("Previous session").withUnit("kWh"), + exposes.numeric("charging_duration", ea.STATE).withDescription("Charging duration last session").withUnit("s"), + ], + }; + }, +}; + export const definitions: DefinitionWithExtend[] = [ { fingerprint: tuya.fingerprint("TS0601", ["_TZE204_e5hpkc6d", "_TZE200_4hbx5cvx", "_TZE200_e5hpkc6d"]), @@ -106,4 +316,290 @@ export const definitions: DefinitionWithExtend[] = [ ota: true, extend: [m.light({configureReporting: true})], }, + { + zigbeeModel: ["Charge"], + model: "Charge", + vendor: "Futurehome", + description: "Futurehome Charge (EV Charger)", + extend: [ + m.deviceAddCustomCluster("haApplianceControl", { + name: "haApplianceControl", + ID: Zcl.Clusters.haApplianceControl.ID, + attributes: { + // ID: 0xef00, + chargingSessionStartT: { + name: "chargingSessionStartT", + ID: 0xef01, + type: Zcl.DataType.UINT32, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + }, + chargingSessionEndT: { + name: "chargingSessionEndT", + ID: 0xef02, + type: Zcl.DataType.UINT32, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + }, + energyMeterStart: { + name: "energyMeterStart", + ID: 0xef03, + type: Zcl.DataType.UINT32, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + }, + energyMeterNow: { + name: "energyMeterNow", + ID: 0xef04, + type: Zcl.DataType.UINT32, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + }, + a5: { + name: "a5", + ID: 0xef05, + type: Zcl.DataType.UINT8, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + write: true, + }, + a6: { + name: "a6", + ID: 0xef06, + type: Zcl.DataType.UINT8, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + write: true, + }, + a7: { + name: "a7", + ID: 0xef07, + type: Zcl.DataType.UINT8, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + write: true, + }, + a8: { + name: "a8", + ID: 0xef08, + type: Zcl.DataType.UINT8, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + write: true, + }, + status: { + name: "status", + ID: 0xef09, + type: Zcl.DataType.UINT8, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + write: true, + }, + aa: { + name: "aa", + ID: 0xef0a, + type: Zcl.DataType.UINT8, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + write: true, + }, + ab: { + name: "ab", + ID: 0xef0b, + type: Zcl.DataType.UINT8, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + write: true, + }, + autoCharge: { + name: "autoCharge", + ID: 0xef0c, + type: Zcl.DataType.UINT8, + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + write: true, + }, + // ID: 0xef0d, + // ID: 0xef0e, + // ID: 0xef0f, + a10: { + name: "a10", + ID: 0xef10, + type: Zcl.DataType.UINT16, // tested with write + manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS, + write: true, + }, + // ID: 0xef11, + }, + commands: {}, + commandsResponse: {}, + }), + futurehomeExtend.chargerStatus(), + futurehomeExtend.charging(), + m.binary({ + name: "cable_locked", + cluster: "closuresDoorLock", + attribute: "operatingMode", + valueOff: ["UNLOCK", 0x00], + valueOn: ["LOCK", 0x02], + description: "Permanently lock cable when not charging.", + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric({ + name: "setpoint_charging_current", + cluster: "genAnalogOutput", + attribute: "presentValue", + description: "Setpoint charging current", + unit: "A", + access: "ALL", + valueMin: 6, + valueMax: 32, + valueStep: 1, + reporting: {min: "10_SECONDS", max: "1_HOUR", change: 1}, + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric({ + name: "charging_current_limit", + cluster: "genAnalogOutput", + attribute: "maxPresentValue", + description: "Maximum charging current.", + unit: "A", + access: "ALL", + valueMin: 6, + valueMax: 32, + valueStep: 1, + entityCategory: "config", + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.binary<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "auto_charge", + cluster: "haApplianceControl", + attribute: "autoCharge", + description: "Automatically start charging when a car is connected.", + valueOff: ["OFF", 0], + valueOn: ["ON", 1], + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + futurehomeExtend.previousSessionEnergy(), + m.numeric<"haElectricalMeasurement", undefined>({ + name: "power", + cluster: "haElectricalMeasurement", + attribute: "totalActivePower", + description: "Power", + unit: "W", + access: "STATE_GET", + reporting: {min: 5, max: "1_HOUR", change: 1}, + }), + m.electricityMeter({ + energy: {divisor: 1000, multiplier: 1}, + power: false, + threePhase: true, + }), + m.numeric<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "energy_meter_start", + cluster: "haApplianceControl", + attribute: "energyMeterStart", + description: "energyMeterStart", + unit: "kWh", + access: "STATE", + scale: 1000, + reporting: {min: 5, max: "1_HOUR", change: 1}, + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "energy_meter_now", + cluster: "haApplianceControl", + attribute: "energyMeterNow", + description: "energyMeterNow", + unit: "kWh", + access: "STATE", + scale: 1000, + reporting: {min: 5, max: "1_HOUR", change: 1}, + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.enumLookup<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "status", + cluster: "haApplianceControl", + attribute: "status", + description: "Status", + lookup: { + plugged_out: 0x00, + off: 0x01, + plugged_in_charging: 0x02, + plugged_in_paused: 0x03, + plugged_in: 0x04, + stopped: 0x05, + }, + access: "STATE_GET", + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "start_t", + cluster: "haApplianceControl", + attribute: "chargingSessionStartT", + description: "Start time of charging session. Time in seconds. Should be converted to date and time.", + access: "STATE", + scale: 1.0, + unit: "s", + reporting: {min: 5, max: "1_HOUR", change: 1}, + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "end_t", + cluster: "haApplianceControl", + attribute: "chargingSessionEndT", + description: "End time of charging session. Time in seconds. Should be converted to date and time.", + access: "STATE", + scale: 1.0, + unit: "s", + reporting: {min: 5, max: "1_HOUR", change: 1}, + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "a5", + cluster: "haApplianceControl", + attribute: "a5", + description: "a5 - not reportable", + access: "STATE_GET", + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "a6", + cluster: "haApplianceControl", + attribute: "a6", + description: "a6", + access: "STATE_GET", + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "a7", + cluster: "haApplianceControl", + attribute: "a7", + description: "a7 - not reportable", + access: "STATE_GET", + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "a8", + cluster: "haApplianceControl", + attribute: "a8", + description: "a8 - not reportable", + access: "STATE_GET", + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "aa", + cluster: "haApplianceControl", + attribute: "aa", + description: "aa", + access: "STATE_GET", + reporting: {min: 5, max: "1_HOUR", change: 1}, + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "ab", + cluster: "haApplianceControl", + attribute: "ab", + description: "ab - not reportable", + access: "STATE_GET", + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + m.numeric<"haApplianceControl", FuturehomeHaApplianceControl>({ + name: "a10", + cluster: "haApplianceControl", + attribute: "a10", + description: "a10 - not reportable", + access: "STATE_GET", + zigbeeCommandOptions: {manufacturerCode: Zcl.ManufacturerCode.FUTUREHOME_AS}, + }), + ], + }, ];