UNPKG

homebridge-daikin-local-platform

Version:

Homebridge platform plugin providing HomeKit support for Daikin air conditioners with local control

616 lines (422 loc) 18.5 kB
import DaikinPlatformLogger from './logger'; import axios from 'axios'; import rateLimit from 'axios-rate-limit'; import { USER_AGENT, ENDPOINT, MIN_REQUEST_INTERVAL_MS, REQUEST_TIMEOUT_MS } from './const'; export const CLIMATE_MODE_FAN = '0000'; export const CLIMATE_MODE_HEATING = '0100'; export const CLIMATE_MODE_COOLING = '0200'; export const CLIMATE_MODE_AUTO = '0300'; export const CLIMATE_MODE_DEHUMIDIFY = '0500'; export const CLIMATE_MODE_HUMIDIFY = '0800'; export const CLIMATE_FAN_SPEED_AUTO = '0A00'; export const CLIMATE_FAN_SPEED_SLIENT = '0B00'; export const CLIMATE_FAN_SPEED_1 = '0300'; export const CLIMATE_FAN_SPEED_2 = '0400'; export const CLIMATE_FAN_SPEED_3 = '0500'; export const CLIMATE_FAN_SPEED_4 = '0600'; export const CLIMATE_FAN_SPEED_5 = '0700'; const CLIMATE_OPERATE_ON = '00'; const CLIMATE_OPERATE_OFF = '01'; const CLIMATE_OPERATE_SETTING = '02'; // Target temperature lives under a mode-dependent `pn` key inside e_1002/e_3001. const TARGET_TEMP_PN_BY_MODE: Record<string, string> = { [CLIMATE_MODE_HEATING]: 'p_03', [CLIMATE_MODE_COOLING]: 'p_02', [CLIMATE_MODE_AUTO]: 'p_1D', }; // Fan speed lives under a mode-dependent `pn` key inside e_1002/e_3001. // Modes not listed here fall back to the cooling key (p_09). const FAN_SPEED_PN_BY_MODE: Record<string, string> = { [CLIMATE_MODE_FAN]: 'p_28', [CLIMATE_MODE_DEHUMIDIFY]: 'p_27', [CLIMATE_MODE_AUTO]: 'p_26', [CLIMATE_MODE_HEATING]: 'p_0A', [CLIMATE_MODE_COOLING]: 'p_09', }; const FAN_SPEED_PN_DEFAULT = 'p_09'; // Bidirectional mapping between Daikin fan-speed codes, the HomeKit rotation-speed // step (0 = auto ... 6 = level 5) and the human-readable name. export const FAN_SPEED_TABLE: { code: string; number: number; name: string }[] = [ { code: CLIMATE_FAN_SPEED_AUTO, number: 0, name: 'Auto' }, { code: CLIMATE_FAN_SPEED_SLIENT, number: 1, name: 'Silent' }, { code: CLIMATE_FAN_SPEED_1, number: 2, name: '1' }, { code: CLIMATE_FAN_SPEED_2, number: 3, name: '2' }, { code: CLIMATE_FAN_SPEED_3, number: 4, name: '3' }, { code: CLIMATE_FAN_SPEED_4, number: 5, name: '4' }, { code: CLIMATE_FAN_SPEED_5, number: 6, name: '5' }, ]; const COMMAND_QUERY = '{"requests":[{"op":2,"to":"/dsiot/edge.adp_i?filter=pv"},{"op":2,"to":"/dsiot/edge.adp_d?filter=pv"},{"op":2,"to":"/dsiot/edge.adp_f?filter=pv"},{"op":2,"to":"/dsiot/edge.dev_i?filter=pv"},{"op":2,"to":"/dsiot/edge/adr_0100.dgc_status?filter=pv"}]}'; const COMMAND_QUERY_WITH_MD = '{"requests":[{"op":2,"to":"/dsiot/edge.adp_i?filter=pv"},{"op":2,"to":"/dsiot/edge.adp_d?filter=pv"},{"op":2,"to":"/dsiot/edge.adp_f?filter=pv"},{"op":2,"to":"/dsiot/edge.dev_i?filter=pv"},{"op":2,"to":"/dsiot/edge/adr_0100.dgc_status"},{"op":2,"to":"/dsiot/edge/adr_0200.dgc_status"}]}'; const http = rateLimit(axios.create(), { maxRequests: 1, perMilliseconds: 500 }); export class DaikinDevice { protected _IP: string; protected _Response: object; protected _log: DaikinPlatformLogger; protected _lastUpdateTimestamp: number = 0; protected _callback: ((device: DaikinDevice) => void) | null = null; // Shared promise for an in-flight query so concurrent reads don't each hit the unit. protected _inflightQuery: Promise<any> | null = null; constructor( public readonly IP: string, private readonly log: DaikinPlatformLogger, ) { this._IP = IP; this._Response = {}; this._log = log; } public setCallback(callback: (device: DaikinDevice) => void) { this._callback = callback; } protected async post(data: string): Promise<any> { return http.request({ method: 'post', url: `http://${this._IP}${ENDPOINT}`, headers: { 'Accept': 'application/json; charset=UTF-8', 'Content-Type': 'application/json', 'User-Agent': USER_AGENT, }, data, timeout: REQUEST_TIMEOUT_MS, }); } public async queryDevice(bForce:boolean = false): Promise<any> { if(!bForce) { if((Date.now() - this._lastUpdateTimestamp) < MIN_REQUEST_INTERVAL_MS) { this.log.debug(`Daikin - queryDevice('${this._IP}'): Skipping query as last update was less than ${MIN_REQUEST_INTERVAL_MS} ms ago`); return this._Response; } // Coalesce concurrent (non-forced) reads onto a single request. if(this._inflightQuery) { return this._inflightQuery; } } const request = this._doQuery(); if(!bForce) { this._inflightQuery = request; } try { return await request; } finally { if(this._inflightQuery === request) { this._inflightQuery = null; } } } protected async _doQuery(): Promise<any> { try{ const response = await this.post(COMMAND_QUERY_WITH_MD); this._lastUpdateTimestamp = Date.now(); if(response.status === 200) { //this.log.debug(`Daikin - queryDevice('${this._IP}'): Response: '${JSON.stringify(response.data)}'`); this._Response = response.data; return response.data; } this.log.debug(`Daikin - queryDevice('${this._IP}'): Error: Invalid response status code: '${response.status}'`); } catch(e) { this.log.debug(`Daikin - queryDevice('${this._IP}'): Error: '${e}'`); } return undefined; } public async fetchDeviceStatus(bForce:boolean = false): Promise<boolean> { const response = await this.queryDevice(bForce); if(response === undefined) { this.log.error(`Daikin - fetchDeviceStatus(${bForce}): Error: No response from device`); return false; } if(!this.getMacAddress()){ this.log.error(`Daikin - fetchDeviceStatus(${bForce}): Error: ${this._IP} no MAC address found`); this.log.debug(`Daikin - fetchDeviceStatus(${bForce}): Response: '${this._Response}'`); return false; } this.log.debug(`Daikin - fetchDeviceStatus(${bForce}): Name: ${this.getDeviceName()} MAC:${this.getMacAddress()} Power:${this.getPowerStatus()} Temp:${this.getIndoorTemperature()} Humidity:${this.getIndoorHumidity()} Target Temp:${this.getTargetTemperature()}' Mode:${this.getOperationModeName()} FanSpeed:${this.getFanSpeedName()} `); if(this._callback) { this._callback(this); } return true; } public async setShowSSID(bShow: boolean): Promise<boolean> { this.log.debug(`Daikin - setShowSSID(${bShow}): Name: ${this.getDeviceName()}`); const command = {"requests":[{"op":3,"to":"/dsiot/edge.adp_d","pc":{"pn":"adp_d","pch":[{"pn":"disp_ssid","pv": bShow ? 0 :1 }]}}]}; try{ const response = await this.post(JSON.stringify(command)); return response.status === 200; }catch(e) { this.log.debug(`Daikin - setShowSSID('${this._IP}'): Error: '${e}'`); } return false; } public getMacAddress(): string { return this.extractValue(this._Response, '/dsiot/edge.adp_i', 'mac'); } public getSSID(): string { return this.extractValue(this._Response, '/dsiot/edge.adp_i', 'ssid'); } public getDeviceName(): string { return this.extractValue(this._Response, '/dsiot/edge.adp_d', 'name'); } public getDeviceReg(): string { return this.extractValue(this._Response, '/dsiot/edge.adp_i', 'reg'); } public getDeviceType(): string { return this.extractValue(this._Response, '/dsiot/edge.dev_i', 'type') + this.extractValue(this._Response, '/dsiot/edge.adp_i', 'enlv'); } public getFirmwareVersion(): string { return this.extractValue(this._Response, '/dsiot/edge.adp_i', 'ver'); } public getDeviceIP(): string { return this._IP; } public getPowerStatus(): boolean { return this.extractValue(this._Response, '/dsiot/edge/adr_0100.dgc_status', 'e_1002/e_A002/p_01') === '01'; } public getIndoorTemperature(): number { return parseInt(this.extractValue(this._Response, '/dsiot/edge/adr_0100.dgc_status', 'e_1002/e_A00B/p_01'), 16); } public getIndoorHumidity(): number { return parseInt(this.extractValue(this._Response, '/dsiot/edge/adr_0100.dgc_status', 'e_1002/e_A00B/p_02'), 16); } // Outdoor temperature lives on the outdoor unit at adr_0200, encoded as // little-endian signed int16 of (temperature * 2). Returns NaN if absent. public getOutdoorTemperature(): number { const raw = this.extractValue(this._Response, '/dsiot/edge/adr_0200.dgc_status', 'e_1003/e_A00D/p_01'); if (typeof raw !== 'string' || raw.length !== 4) { return NaN; } const lo = parseInt(raw.substring(0, 2), 16); const hi = parseInt(raw.substring(2, 4), 16); let val = (hi << 8) | lo; if (val & 0x8000) { val -= 0x10000; } return val / 2; } //0000:fan 0100:heating 0200:cooling 0300:auto 0500:dehumidify public getOperationMode(): string { return this.extractValue(this._Response, '/dsiot/edge/adr_0100.dgc_status', 'e_1002/e_3001/p_01'); } public getOperationModeName(): string { const mode = this.getOperationMode(); if(mode === CLIMATE_MODE_FAN) { return 'Fan'; } else if(mode === CLIMATE_MODE_DEHUMIDIFY) { return 'Dehumidify'; } else if(mode === CLIMATE_MODE_AUTO) { return 'Auto'; } else if(mode === CLIMATE_MODE_HEATING) { return 'Heating'; } else if(mode === CLIMATE_MODE_COOLING) { return 'Cooling'; } return 'Unknown'; } public getTargetTemperatureWithMode(mode:string): number { const pn = TARGET_TEMP_PN_BY_MODE[mode]; if(!pn) { this.log.debug(`Daikin - getTargetTemperature(): Error: Invalid mode: '${mode}'`); return 0; } return parseInt(this.extractValue(this._Response, '/dsiot/edge/adr_0100.dgc_status', 'e_1002/e_3001/' + pn), 16) / 2.0; } public getTargetTemperature(): number { const mode = this.getOperationMode(); return this.getTargetTemperatureWithMode(mode); } public getTargetTemperatureRange(): number[] { const mode = this.getOperationMode(); const pn = TARGET_TEMP_PN_BY_MODE[mode]; if(!pn) { this.log.debug(`Daikin - getTargetTemperatureRange(): Error: Invalid mode: '${mode}'`); return [0, 0]; } const md = this.extractObject(this._Response, '/dsiot/edge/adr_0100.dgc_status', 'e_1002/e_3001/' + pn); const min = md ? parseInt(md['md']['mi'], 16) / 2.0 : 0; const max = md ? parseInt(md['md']['mx'], 16) / 2.0 : 0; return [min, max]; } public getCoolingThresholdTemperatureRange(): number[] { const md = this.extractObject(this._Response, '/dsiot/edge/adr_0100.dgc_status', 'e_1002/e_3001/p_02'); const min = md ? parseInt(md['md']['mi'], 16) / 2.0 : 0; const max = md ? parseInt(md['md']['mx'], 16) / 2.0 : 0; return [min, max]; } public getHeatingThresholdTemperatureRange(): number[] { const md = this.extractObject(this._Response, '/dsiot/edge/adr_0100.dgc_status', 'e_1002/e_3001/p_03'); const min = md ? parseInt(md['md']['mi'], 16) / 2.0 : 0; const max = md ? parseInt(md['md']['mx'], 16) / 2.0 : 0; return [min, max]; } public getFanSpeed(): string { const mode = this.getOperationMode(); const pn = FAN_SPEED_PN_BY_MODE[mode] ?? FAN_SPEED_PN_DEFAULT; return this.extractValue(this._Response, '/dsiot/edge/adr_0100.dgc_status', 'e_1002/e_3001/' + pn); } public getFanSpeedName(): string { const speed = this.getFanSpeed(); return FAN_SPEED_TABLE.find(entry => entry.code === speed)?.name ?? 'Unknown'; } public getFanSpeedNumber(): number { const speed = this.getFanSpeed(); return FAN_SPEED_TABLE.find(entry => entry.code === speed)?.number ?? 0; } public getMotionDetection(): boolean { return this.extractValue(this._Response, '/dsiot/edge/adr_0100.dgc_status', 'e_1002/e_3003/p_27') === '01'; } public async setMotionDetection(bEnable: boolean): Promise<boolean> { const command = [{"pn": "e_3003", "pch": [{"pn": "p_27", "pv": bEnable ? '01':'00'}]}]; return await this.sendCommand(command); } public async setPowerStatus(power: boolean): Promise<boolean> { // p_2D is the operation-type flag. Sending SETTING ('02') makes the unit treat the // stop as a mere config change, so it skips the post-stop 内部クリーン (mould-proof) dry // cycle. Sending the genuine OFF ('01') makes it a normal user stop, which triggers the // dry cycle when the unit's auto-internal-clean setting is enabled — matching the remote. const operate = power ? CLIMATE_OPERATE_ON : CLIMATE_OPERATE_OFF; const command = [{"pn": "e_3003", "pch": [{"pn": "p_2D", "pv": operate }]}, {"pn": "e_A002","pch": [{"pn": "p_01", "pv": power ? '01':'00'}]}]; return await this.sendCommand(command); } public async setOperationMode(mode: string): Promise<boolean> { const command = [{"pn": "e_3003", "pch": [{"pn": "p_2D", "pv": CLIMATE_OPERATE_SETTING}]}, {"pn": "e_3001","pch": [{"pn": "p_01", "pv": mode}]}]; return await this.sendCommand(command); } public async setTargetTemperature(temperature: number): Promise<boolean> { const mode = this.getOperationMode(); const pn = TARGET_TEMP_PN_BY_MODE[mode]; if(!pn) { return false; } const pv = (temperature * 2).toString(16); const command = [{"pn": "e_3003", "pch": [{"pn": "p_2D", "pv": CLIMATE_OPERATE_SETTING}]}, {"pn": "e_3001","pch": [{"pn": pn, "pv": pv}]}]; if(mode === CLIMATE_MODE_COOLING) { this.pushObject(command, 'e_3001', {"pn": "p_0B", "pv": '0A'}); this.pushObject(command, 'e_3001', {"pn": "p_0C", "pv": '01'}); } return await this.sendCommand(command); } public async setFanSpeed(speed: string): Promise<boolean> { const mode = this.getOperationMode(); const pn = FAN_SPEED_PN_BY_MODE[mode] ?? FAN_SPEED_PN_DEFAULT; const command = [{"pn": "e_3003", "pch": [{"pn": "p_2D", "pv": CLIMATE_OPERATE_SETTING}]}, {"pn": "e_3001","pch": [{"pn": pn, "pv": speed}]}]; return await this.sendCommand(command); } public extractValue(responsesData: object, fr: string, path: string): any | undefined { const element = this.extractObject(responsesData, fr, path); return element ? element['pv'] : undefined; } public extractObject(responsesData: object, fr: string, path: string): object | undefined { try { if(responsesData === undefined || responsesData.hasOwnProperty('responses') === false) { this.log.debug('Daikin - extractObject(): Error: No responses object found'); return undefined; } let currentObject = responsesData['responses']; for(const response of currentObject) { if (response['fr'] === fr) { currentObject = response['pc']['pch']; } } const pathKeys = path.split('/'); for (const key of pathKeys) { try{ for(const currentObjectElement of currentObject) { if (currentObjectElement['pn'] === key && currentObjectElement.hasOwnProperty('pch')) { currentObject = currentObjectElement['pch']; break; } else if (currentObjectElement['pn'] === key && currentObjectElement.hasOwnProperty('pv')) { return currentObjectElement; } } } catch (e) { this.log.debug('Daikin - extractValue(): Error:' + e); } } } catch (e) { this.log.debug('Daikin - extractValue(): Error:' + e); } this.log.debug('Daikin - extractValue(): Error: No value found for path:' + path); return undefined; } // const command = [{"pn": "e_3003", "pch": [{"pn": "p_2D", "pv": CLIMATE_OPERATE_SETTING}]}, {"pn": "e_3001","pch": [{"pn": pn, "pv": speed}]}]; public pushObject(jsonData: Object, pn: string, obj: object): object | undefined { try{ for(const i in jsonData) { const currentObject = jsonData[i]; if (currentObject['pn'] === pn && currentObject.hasOwnProperty('pch')) { currentObject['pch'].push(obj); return jsonData; } } }catch (e) { this.log.debug('Daikin - pushObject(): Error:' + e); } return undefined; } protected async sendCommand(command: object): Promise<boolean> { const param = {"requests": [{"op": 3,"to": "/dsiot/edge/adr_0100.dgc_status","pc": {"pn": "dgc_status","pch": [{"pn": "e_1002","pch": command}]}}]}; try{ const response = await this.post(JSON.stringify(param)); if(response.status === 200) { this.log.debug(`Daikin - sendCommand('${this._IP}'): '${JSON.stringify(param)} ' : Response: '${JSON.stringify(response.data)}'`); } else{ this.log.debug(`Daikin - sendCommand('${this._IP}'): '${JSON.stringify(param)} ' : Error: Invalid response status code: '${response.status}'`); } await this.fetchDeviceStatus(true); return response.status === 200; } catch(e) { this.log.debug(`Daikin - sendCommand('${this._IP}'): Error: '${e}'`); } return false; } } const FETCH_ATTEMPTS = 3; const FETCH_RETRY_DELAY_MS = 2000; export class DaikinLocalAPI { private _devices: DaikinDevice[]; constructor( private readonly log: DaikinPlatformLogger, ) { this._devices = []; } public async fetchDevices(climateIPs:string[], bForce:boolean = false): Promise<DaikinDevice[]> { this.log.debug('Daikin: fetchDevices()'); this._devices = []; for(const ip of climateIPs) { const daikinDevice = new DaikinDevice(ip, this.log); if(await this.fetchWithRetry(daikinDevice, bForce)) { this._devices.push(daikinDevice); } else { this.log.error(`Daikin: device at ${ip} did not respond after ${FETCH_ATTEMPTS} attempts - skipping.`); } } return this._devices; } // A device that is briefly unreachable at startup (e.g. still booting) would // otherwise be dropped until Homebridge restarts; retry a few times first. private async fetchWithRetry(device: DaikinDevice, bForce: boolean): Promise<boolean> { for(let attempt = 1; attempt <= FETCH_ATTEMPTS; attempt++) { if(await device.fetchDeviceStatus(bForce || attempt > 1)) { return true; } if(attempt < FETCH_ATTEMPTS) { await new Promise(resolve => setTimeout(resolve, FETCH_RETRY_DELAY_MS)); } } return false; } }