diff --git a/src/devices/owon.ts b/src/devices/owon.ts index 54b855dec6682..a2687bb4a696e 100644 --- a/src/devices/owon.ts +++ b/src/devices/owon.ts @@ -1,4 +1,5 @@ import {Zcl} from "zigbee-herdsman"; +import type {ClusterDefinition} from "zigbee-herdsman/dist/zspec/zcl/definition/tstype"; import * as fz from "../converters/fromZigbee"; import * as tz from "../converters/toZigbee"; import * as exposes from "../lib/exposes"; @@ -9,6 +10,99 @@ import type {DefinitionWithExtend, Fz, KeyValue, Tz} from "../lib/types"; const e = exposes.presets; const ea = exposes.access; +interface OwonAcControl { + attributes: Record; + commands: { + oneKeyPairingRequest: {oneKeyPairingStart: number}; // 0x00 end, 0x01 start + writePairingCode: {pairingCode: number}; + readPairingCodeRequest: Record; + }; + commandResponses: never; +} + +const owonExtendChecks = { + parseOneKeyPairingInput: (input: unknown) => { + const action = String(input).toLowerCase().trim(); + + let startParam: number; + if (action === "start" || action === "on" || action === "true") { + startParam = 0x01; + } else if (action === "end" || action === "off" || action === "false") { + startParam = 0x00; + } else { + throw new Error(`Invalid value for one_key_pairing: expected "start"/"on"/"true" or "end"/"off"/"false", got "${input}"`); + } + + return { + payload: { + oneKeyPairingStart: startParam, + }, + }; + }, + + parsePairingCodeInput: (input: unknown) => { + if (input === undefined || input === null) { + throw new Error("pairing_code is required"); + } + + const codeStr = typeof input === "number" ? Math.trunc(input).toString() : String(input).trim(); + + if (!/^\d+$/.test(codeStr)) { + throw new Error(`Invalid pairing_code "${codeStr}", must be decimal number`); + } + + const codeNum = Number(codeStr); + + if (codeNum < 0 || codeNum > 65535) { + throw new Error(`Invalid pairing_code "${codeStr}", must be between 0 and 65535`); + } + + return { + payload: { + pairingCode: codeNum, + }, + }; + }, +}; + +const OwonClustersDefinition: {[s: string]: ClusterDefinition} = { + manuSpecificOwonAc: { + ID: 0xffac, + manufacturerCode: Zcl.ManufacturerCode.OWON_TECHNOLOGY_INC, + attributes: {}, + commands: { + oneKeyPairingRequest: { + ID: 0x52, + parameters: [ + {name: "oneKeyPairingStart", type: Zcl.DataType.UINT8}, // 0x00 end, 0x01 start + ], + }, + writePairingCode: { + ID: 0x20, + parameters: [{name: "pairingCode", type: Zcl.DataType.UINT16}], + }, + readPairingCodeRequest: { + ID: 0x00, + parameters: [], + }, + }, + commandsResponse: { + oneKeyPairingResponse: { + ID: 0x52, + parameters: [{name: "receiveStatus", type: Zcl.DataType.UINT8}], + }, + oneKeyPairingResultUpdate: { + ID: 0x80, + parameters: [], + }, + readPairingCodeResponse: { + ID: 0x00, + parameters: [{name: "pairingCode", type: Zcl.DataType.UINT16}], + }, + }, + }, +}; + interface OwonFallDetection { attributes: { status: number; @@ -185,6 +279,78 @@ const fzLocal = { return result; }, } satisfies Fz.Converter<"fallDetectionOwon", OwonFallDetection, ["attributeReport", "readResponse"]>, + + owonAcOneKeyPairingResponse: { + cluster: "manuSpecificOwonAc", + type: ["commandOneKeyPairingResponse", "commandOneKeyPairingResultUpdate"], + convert: (model, msg, publish, options, meta) => { + if (msg.meta?.manufacturerCode !== Zcl.ManufacturerCode.OWON_TECHNOLOGY_INC) { + return {}; + } + + const payload: KeyValue = {}; + + if (msg.type === "commandOneKeyPairingResponse") { + const status = msg.data?.receiveStatus; + if (status !== undefined) { + payload.one_key_pairing_status = status === 0x00 ? "SUCCESS" : "FAILURE"; + } + } + + if (msg.type === "commandOneKeyPairingResultUpdate") { + let buffer: Buffer | undefined; + + if (Buffer.isBuffer(msg.meta?.rawData)) { + buffer = msg.meta.rawData; + } + + if (!buffer || buffer.length < 6) { + payload.one_key_pairing_result = { + count: 0, + codes_found: [], + }; + return payload; + } + + const payloadOffset = 5; + const count = buffer.readUInt8(payloadOffset); + const codes: number[] = []; + + let offset = payloadOffset + 1; + for (let i = 0; i < count; i++) { + if (offset + 1 >= buffer.length) break; + codes.push(buffer.readUInt16LE(offset)); + offset += 2; + } + + payload.one_key_pairing_result = { + count, + codes_found: codes, + }; + } + + return payload; + }, + // biome-ignore lint/suspicious/noExplicitAny: third-party converter signature requires any + } satisfies Fz.Converter<"manuSpecificOwonAc", undefined, any>, + + owonAcReadPairingCodeResponse: { + cluster: "manuSpecificOwonAc", + type: ["commandReadPairingCodeResponse"], + convert: (model, msg, publish, options, meta) => { + if (msg.data?.pairingCode !== undefined) { + const code = msg.data.pairingCode; + const displayCode = code === 0xffff ? null : code; + + return { + pairing_code_current: displayCode, + }; + } + + return {}; + }, + // biome-ignore lint/suspicious/noExplicitAny: third-party converter signature requires any + } satisfies Fz.Converter<"manuSpecificOwonAc", undefined, any>, }; const tzLocal = { @@ -242,6 +408,50 @@ const tzLocal = { ); }, } satisfies Tz.Converter, + + owonAcOneKeyPairing: { + key: ["one_key_pairing"], + convertSet: async (entity, key, value, meta) => { + meta.state.one_key_pairing_status = null; + meta.state.one_key_pairing_result = null; + const commandWrapper = owonExtendChecks.parseOneKeyPairingInput(value); + + await entity.command<"manuSpecificOwonAc", "oneKeyPairingRequest", OwonAcControl>( + "manuSpecificOwonAc", + "oneKeyPairingRequest", + commandWrapper.payload, + {disableDefaultResponse: true}, + ); + }, + } satisfies Tz.Converter, + + owonAcWritePairingCode: { + key: ["pairing_code"], + convertSet: async (entity, key, value, meta) => { + const commandWrapper = owonExtendChecks.parsePairingCodeInput(value); + + meta.state.pairing_code = String(value); + + await entity.command<"manuSpecificOwonAc", "writePairingCode", OwonAcControl>( + "manuSpecificOwonAc", + "writePairingCode", + commandWrapper.payload, + {disableDefaultResponse: true}, + ); + }, + } satisfies Tz.Converter, + + owonAcReadPairingCode: { + key: ["pairing_code_current"], + convertGet: async (entity, key, meta) => { + await entity.command<"manuSpecificOwonAc", "readPairingCodeRequest", OwonAcControl>( + "manuSpecificOwonAc", + "readPairingCodeRequest", + {}, + {disableDefaultResponse: true}, + ); + }, + } satisfies Tz.Converter, }; export const definitions: DefinitionWithExtend[] = [ @@ -250,7 +460,14 @@ export const definitions: DefinitionWithExtend[] = [ model: "WSP402", vendor: "OWON", description: "Smart plug", - extend: [m.onOff(), m.electricityMeter({cluster: "metering"})], + extend: [ + m.onOff({powerOnBehavior: false}), + m.electricityMeter({ + cluster: "metering", + }), + + m.forcePowerSource({powerSource: "Mains (single phase)"}), + ], }, { zigbeeModel: ["WSP403-E"], @@ -258,7 +475,14 @@ export const definitions: DefinitionWithExtend[] = [ vendor: "OWON", whiteLabel: [{vendor: "Oz Smart Things", model: "WSP403"}], description: "Smart plug", - extend: [m.onOff(), m.electricityMeter({cluster: "metering"}), m.forcePowerSource({powerSource: "Mains (single phase)"})], + extend: [ + m.onOff({powerOnBehavior: false}), + m.electricityMeter({ + cluster: "metering", + }), + + m.forcePowerSource({powerSource: "Mains (single phase)"}), + ], }, { zigbeeModel: ["WSP404"], @@ -331,6 +555,60 @@ export const definitions: DefinitionWithExtend[] = [ await reporting.thermostatAcLouverPosition(endpoint); }, }, + { + zigbeeModel: ["AC221"], + model: "AC221", + vendor: "OWON", + description: "AC controller / IR blaster", + extend: [m.deviceAddCustomCluster("manuSpecificOwonAc", OwonClustersDefinition.manuSpecificOwonAc)], + fromZigbee: [fz.fan, fz.thermostat, fzLocal.owonAcOneKeyPairingResponse, fzLocal.owonAcReadPairingCodeResponse], + toZigbee: [ + tz.fan_mode, + tz.thermostat_system_mode, + tz.thermostat_occupied_heating_setpoint, + tz.thermostat_occupied_cooling_setpoint, + tz.thermostat_ac_louver_position, + tz.thermostat_local_temperature, + tzLocal.owonAcOneKeyPairing, + tzLocal.owonAcWritePairingCode, + tzLocal.owonAcReadPairingCode, + ], + + exposes: [ + // --- One Key Pairing Exposes --- + e.enum("one_key_pairing", ea.SET, ["start", "end"]), + e.text("one_key_pairing_status", ea.STATE).withDescription("Status of the last one key pairing request command."), + e.text("one_key_pairing_result", ea.STATE).withDescription("Final result of one key pairing process (JSON string, device reported)."), + e + .numeric("pairing_code_current", ea.STATE_GET) + .withDescription("Currently set pairing code on the device (null if invalid)") + .withUnit("") + .withValueMin(0) + .withValueMax(65535), + e.text("pairing_code", ea.SET).withDescription("Manually write pairing code to device (decimal digits only, e.g. 123456)."), + e + .climate() + .withSystemMode(["off", "heat", "cool", "auto", "dry", "fan_only"]) + .withSetpoint("occupied_heating_setpoint", 8, 30, 1) + .withSetpoint("occupied_cooling_setpoint", 8, 30, 1) + .withLocalTemperature(), + e.fan().withModes(["low", "medium", "high", "on", "auto"]), + ], + + configure: async (device, coordinatorEndpoint) => { + const endpoint = device.getEndpoint(1); + const binds = ["genBasic", "genIdentify", "genTime", "hvacThermostat", "hvacFanCtrl"]; + + await reporting.bind(endpoint, coordinatorEndpoint, binds); + + await reporting.thermostatTemperature(endpoint, {min: 60, max: 600, change: 0.1}); + await reporting.thermostatOccupiedHeatingSetpoint(endpoint); + await reporting.thermostatOccupiedCoolingSetpoint(endpoint); + await reporting.thermostatSystemMode(endpoint); + + await reporting.fanMode(endpoint); + }, + }, { zigbeeModel: ["THS317"], model: "THS317", @@ -604,19 +882,19 @@ export const definitions: DefinitionWithExtend[] = [ ID: 0xfd00, manufacturerCode: Zcl.ManufacturerCode.OWON_TECHNOLOGY_INC, attributes: { - status: {ID: 0x0000, type: Zcl.DataType.ENUM8, write: true, max: 0xff}, - breathing_rate: {ID: 0x0002, type: Zcl.DataType.UINT8, write: true, max: 0xff}, - location_x: {ID: 0x0003, type: Zcl.DataType.INT16, write: true, min: -32768}, - location_y: {ID: 0x0004, type: Zcl.DataType.INT16, write: true, min: -32768}, - bedUpperLeftX: {ID: 0x0100, type: Zcl.DataType.INT16, write: true, min: -32768}, - bedUpperLeftY: {ID: 0x0101, type: Zcl.DataType.INT16, write: true, min: -32768}, - bedLowerRightX: {ID: 0x0102, type: Zcl.DataType.INT16, write: true, min: -32768}, - bedLowerRightY: {ID: 0x0103, type: Zcl.DataType.INT16, write: true, min: -32768}, - doorCenterX: {ID: 0x0108, type: Zcl.DataType.INT16, write: true, min: -32768}, - doorCenterY: {ID: 0x0109, type: Zcl.DataType.INT16, write: true, min: -32768}, - leftFallDetectionRange: {ID: 0x010c, type: Zcl.DataType.UINT16, write: true, max: 0xffff}, - rightFallDetectionRange: {ID: 0x010d, type: Zcl.DataType.UINT16, write: true, max: 0xffff}, - frontFallDetectionRange: {ID: 0x010e, type: Zcl.DataType.UINT16, write: true, max: 0xffff}, + status: {ID: 0x0000, type: Zcl.DataType.ENUM8}, + breathing_rate: {ID: 0x0002, type: Zcl.DataType.UINT8}, + location_x: {ID: 0x0003, type: Zcl.DataType.INT16}, + location_y: {ID: 0x0004, type: Zcl.DataType.INT16}, + bedUpperLeftX: {ID: 0x0100, type: Zcl.DataType.INT16}, + bedUpperLeftY: {ID: 0x0101, type: Zcl.DataType.INT16}, + bedLowerRightX: {ID: 0x0102, type: Zcl.DataType.INT16}, + bedLowerRightY: {ID: 0x0103, type: Zcl.DataType.INT16}, + doorCenterX: {ID: 0x0108, type: Zcl.DataType.INT16}, + doorCenterY: {ID: 0x0109, type: Zcl.DataType.INT16}, + leftFallDetectionRange: {ID: 0x010c, type: Zcl.DataType.UINT16}, + rightFallDetectionRange: {ID: 0x010d, type: Zcl.DataType.UINT16}, + frontFallDetectionRange: {ID: 0x010e, type: Zcl.DataType.UINT16}, }, commands: {}, commandsResponse: {}, @@ -648,13 +926,19 @@ export const definitions: DefinitionWithExtend[] = [ vendor: "OWON", description: "Smart plug with doorbell press indicator", extend: [ - m.onOff({endpointNames: ["l1", "l2", "l3"]}), + m.deviceEndpoints({ + endpoints: { + l1: 1, + l2: 2, + l3: 3, + }, + }), + m.onOff({endpointNames: ["l1", "l2", "l3"], powerOnBehavior: false}), m.iasZoneAlarm({ zoneType: "contact", zoneAttributes: ["alarm_2"], }), ], - endpoint: (device) => ({l1: 1, l2: 2, l3: 3}), configure: async (device, coordinatorEndpoint) => { const ep2 = device.getEndpoint(2); if (ep2) {