import * as net from 'net'; import { HttpClient } from './http.js'; import { ApplianceValues } from './values.js'; import { parseResponse } from './response.js'; import { getName } from './discovery.js'; import type { TranslationMap } from './types.js'; import { ATTR_TOTAL, ATTR_COOL, ATTR_HEAT, TIME_TODAY, TIME_YESTERDAY, TIME_LAST_7_DAYS, TIME_THIS_YEAR, TIME_LAST_YEAR, } from './types.js'; const ENERGY_CONSUMPTION_MAX_HISTORY_MS = 6 * 60 * 60 * 1000; interface EnergyConsumptionParserConfig { dimension: string; reducer: (values: number[]) => number; divider: number; } interface EnergyConsumptionState { datetime: Date; firstState: boolean; today: number | null; yesterday: number | null; } export class Appliance { static TRANSLATIONS: TranslationMap = {}; static VALUES_TRANSLATION: Record = {}; static VALUES_SUMMARY: string[] = []; static INFO_RESOURCES: string[] = []; static MAX_CONCURRENT_REQUESTS = 4; public baseUrl: string; public deviceIp: string; public headers: Record = {}; protected httpClient: HttpClient; protected _energyConsumptionHistory: Map = new Map(); protected _pendingRequests: Map> = new Map(); protected _requestSemaphore: { count: number; waiters: (() => void)[] }; protected ENERGY_CONSUMPTION_PARSERS: Record = { [`${ATTR_TOTAL}_${TIME_TODAY}`]: { dimension: 'datas', reducer: (values) => values[values.length - 1], divider: 1000, }, [`${ATTR_COOL}_${TIME_TODAY}`]: { dimension: 'curr_day_cool', reducer: (values) => values.reduce((a, b) => a + b, 0), divider: 10, }, [`${ATTR_HEAT}_${TIME_TODAY}`]: { dimension: 'curr_day_heat', reducer: (values) => values.reduce((a, b) => a + b, 0), divider: 10, }, [`${ATTR_TOTAL}_${TIME_YESTERDAY}`]: { dimension: 'datas', reducer: (values) => values[values.length - 2], divider: 1000, }, [`${ATTR_COOL}_${TIME_YESTERDAY}`]: { dimension: 'prev_1day_cool', reducer: (values) => values.reduce((a, b) => a + b, 0), divider: 10, }, [`${ATTR_HEAT}_${TIME_YESTERDAY}`]: { dimension: 'prev_1day_heat', reducer: (values) => values.reduce((a, b) => a + b, 0), divider: 10, }, [`${ATTR_TOTAL}_${TIME_LAST_7_DAYS}`]: { dimension: 'datas', reducer: (values) => values.reduce((a, b) => a + b, 0), divider: 1000, }, [`${ATTR_TOTAL}_${TIME_THIS_YEAR}`]: { dimension: 'this_year', reducer: (values) => values.reduce((a, b) => a + b, 0), divider: 1, }, [`${ATTR_TOTAL}_${TIME_LAST_YEAR}`]: { dimension: 'previous_year', reducer: (values) => values.reduce((a, b) => a + b, 0), divider: 1, }, }; public values: ApplianceValues = new ApplianceValues(); constructor(deviceId: string, httpsRejectUnauthorized = true) { this.deviceIp = deviceId; this.baseUrl = `http://${this.deviceIp}`; this.httpClient = new HttpClient(this.baseUrl, 30000, {}, httpsRejectUnauthorized); this._requestSemaphore = { count: 0, waiters: [] }; } static daikinToHuman(dimension: string, value: string): string { const translations = this.TRANSLATIONS[dimension]; if (translations) { return translations[value] || value; } return value; } static humanToDaikin(dimension: string, value: string): string { const translations = this.TRANSLATIONS[dimension]; if (translations) { const reversed: Record = {}; for (const [k, v] of Object.entries(translations)) { reversed[v] = k; } return reversed[value] || value; } return value; } static daikinValues(dimension: string): string[] { const translations = this.TRANSLATIONS[dimension]; if (translations) { return Object.values(translations).sort(); } return []; } parseResponse(responseBody: string): Record { return parseResponse(responseBody); } static translateMac(value: string): string { return value.match(/.{1,2}/g)?.join(':') || value; } static discoverIp(deviceId: string): string { try { net.isIP(deviceId); return deviceId; } catch { // Continue to discovery } const deviceName = getName(deviceId); if (deviceName) { return deviceName.ip; } throw new Error(`No device found for ${deviceId}`); } async init(): Promise { throw new Error('Not implemented'); } async getResource(path: string, params?: Record): Promise> { const cacheKey = path + JSON.stringify(params); if (this._pendingRequests.has(cacheKey)) { return this._pendingRequests.get(cacheKey) as Promise>; } const requestPromise = this.doGetResource(path, params); this._pendingRequests.set(cacheKey, requestPromise); try { return await requestPromise; } finally { this._pendingRequests.delete(cacheKey); } } private async doGetResource( path: string, params?: Record, ): Promise> { const url = `${this.baseUrl}/${path}`; let filteredParams = params; if (params && 'pass' in params) { filteredParams = { ...params, pass: '****' }; } try { const response = await this.httpClient.get(url, { params: filteredParams, headers: this.headers, }); if (typeof response === 'string') { return this.parseResponse(response); } return {}; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('404') || errorMessage.includes('Request failed with status code 404')) { return {}; } throw error; } } async updateStatus(resources?: string[]): Promise { if (!resources) { resources = this.getInfoResources(); } resources = resources.filter((resource) => this.values.shouldResourceBeUpdated(resource), ); const results = await Promise.all( resources.map(async (resource) => { const data = await this.getResource(resource); return { resource, data }; }), ); for (const { resource, data } of results) { this.values.updateByResource(resource, data); } this.registerEnergyConsumptionHistory(); } getInfoResources(): string[] { return Appliance.INFO_RESOURCES; } showValues(onlySummary = false): void { const keys = onlySummary ? Appliance.VALUES_SUMMARY : Array.from(this.values.keys()).sort(); for (const key of keys) { if (this.values.has(key)) { const [k, val] = this.represent(key); console.log(`${k.padStart(20)}: ${val}`); } } } represent(key: string): [string, string] { const k = Appliance.VALUES_TRANSLATION[key] || key; let val = this.values.get(key) || ''; if (key === 'mode' && this.values.get('pow') === '0') { val = 'off'; } else if (key === 'mac') { val = Appliance.translateMac(val); } else { val = (this.constructor as typeof Appliance).daikinToHuman(key, val); } return [k, val]; } protected parseNumber(dimension: string): number | null { const value = this.values.get(dimension); if (!value) return null; const parsed = parseFloat(value); return isNaN(parsed) ? null : parsed; } get mac(): string { return this.values.get('mac') || this.deviceIp; } get supportAwayMode(): boolean { return this.values.has('en_hol'); } get supportFanRate(): boolean { return this.values.has('f_rate'); } get supportSwingMode(): boolean { return this.values.has('f_dir'); } get supportOutsideTemperature(): boolean { return this.values.has('otemp'); } get supportHumidity(): boolean { return this.humidity !== null; } get supportAdvancedModes(): boolean { return this.values.has('adv'); } get supportCompressorFrequency(): boolean { return this.values.has('cmpfreq'); } get supportFilterDirty(): boolean { return ( this.values.has('en_filter_sign') && this.values.has('filter_sign_info') && this.parseNumber('en_filter_sign') === 1 ); } get supportZoneCount(): boolean { return this.values.has('en_zone'); } get supportEnergyConsumption(): boolean { const thisYear = this.energyConsumption(ATTR_TOTAL, TIME_THIS_YEAR, false); const lastYear = this.energyConsumption(ATTR_TOTAL, TIME_LAST_YEAR, false); const last7Days = this.energyConsumption(ATTR_TOTAL, TIME_LAST_7_DAYS, false); return ( ((thisYear ?? 0) + (lastYear ?? 0) + (last7Days ?? 0)) > 0 ); } get outsideTemperature(): number | null { return this.parseNumber('otemp'); } get insideTemperature(): number | null { return this.parseNumber('htemp'); } get targetTemperature(): number | null { return this.parseNumber('stemp'); } get compressorFrequency(): number | null { return this.parseNumber('cmpfreq'); } get filterDirty(): number | null { return this.parseNumber('filter_sign_info'); } get zoneCount(): number | null { return this.parseNumber('en_zone'); } get humidity(): number | null { return this.parseNumber('hhum'); } get targetHumidity(): number | null { return this.parseNumber('shum'); } get currentTotalPowerConsumption(): number { return this.currentPowerConsumption('total', undefined, 0.5); } get lastHourCoolEnergyConsumption(): number { return this.currentPowerConsumption( 'cool', 60 * 60 * 1000, 5 * 60 * 1000, ); } get lastHourHeatEnergyConsumption(): number { return this.currentPowerConsumption( 'heat', 60 * 60 * 1000, 5 * 60 * 1000, ); } get fanRate(): string[] { const translations = (this.constructor as typeof Appliance).TRANSLATIONS['f_rate']; if (translations) { return Object.values(translations).map((v) => v.charAt(0).toUpperCase() + v.slice(1)); } return []; } get swingModes(): string[] { const translations = (this.constructor as typeof Appliance).TRANSLATIONS['f_dir']; if (translations) { return Object.values(translations).map((v) => v.charAt(0).toUpperCase() + v.slice(1)); } return []; } async set(_settings: Record): Promise { throw new Error('Not implemented'); } async setHoliday(_mode: string): Promise { throw new Error('Not implemented'); } async setAdvancedMode(_mode: string, _value: string): Promise { throw new Error('Not implemented'); } async setStreamer(_mode: string): Promise { throw new Error('Not implemented'); } get zones(): undefined { return undefined; } async setZone(_zoneId: number, _key: string, _value: string): Promise { throw new Error('Not implemented'); } protected registerEnergyConsumptionHistory(): void { if (!this.supportEnergyConsumption) { return; } for (const mode of [ATTR_TOTAL, ATTR_COOL, ATTR_HEAT]) { const today = this.energyConsumption(mode, TIME_TODAY, false); const yesterday = this.energyConsumption(mode, TIME_YESTERDAY, false); if (today === null) { continue; } const history = this._energyConsumptionHistory.get(mode) || []; const firstState = history.length === 0; const newState: EnergyConsumptionState = { datetime: new Date(), firstState, today, yesterday, }; if (!firstState) { const oldState = history[0]; if ( newState.today === oldState.today && newState.yesterday === oldState.yesterday ) { continue; } } history.unshift(newState); let cutoffIdx = history.length; for (let i = 1; i < history.length; i++) { if ( history[i].datetime.getTime() < new Date().getTime() - ENERGY_CONSUMPTION_MAX_HISTORY_MS ) { cutoffIdx = i + 1; } else { break; } } this._energyConsumptionHistory.set(mode, history.slice(0, cutoffIdx)); } } energyConsumption( mode: string, time: string, _invalidate = true, ): number | null { const key = `${mode}_${time}`; const parser = this.ENERGY_CONSUMPTION_PARSERS[key]; if (!parser) { throw new Error(`Unsupported mode ${mode} on ${time}.`); } const valueStr = this.values.get(parser.dimension); if (valueStr === undefined) { return null; } try { const values = valueStr.split('/').map((x) => parseInt(x, 10)); const value = parser.reducer(values); return value / parser.divider; } catch { return null; } } protected computeDiffEnergy( mode: string, curr: EnergyConsumptionState, prev: EnergyConsumptionState, ): number | null { if (curr.today && prev.today && curr.today > prev.today) { return curr.today - prev.today; } if (curr.yesterday === null) { console.error( `Decreasing today state and missing yesterday state caused an impossible energy consumption measure of ${mode}`, ); return null; } if (prev.yesterday !== null && curr.yesterday !== null && prev.today !== null) { if (curr.yesterday >= prev.today) { return curr.yesterday - prev.today + (curr.today || 0); } } console.error(`Impossible energy consumption measure of ${mode}`); return null; } currentPowerConsumption( mode = ATTR_TOTAL, expDiffTimeValueMs?: number, expDiffTimeMarginFactor?: number, minPower = 0.1, ): number { const expDiffTimeDefaultMs = 5 * 60 * 1000; if (expDiffTimeValueMs === undefined && expDiffTimeMarginFactor === undefined) { expDiffTimeMarginFactor = expDiffTimeDefaultMs; } const history = this._energyConsumptionHistory.get(mode); if (!history || history.length === 0) { return 0; } const now = new Date(); let energyToLog = 0; let expDiffTimeMs: number | null = null; let estPower = 0; for (let i = 0; i < history.length - 1; i++) { const prev = history[i]; const curr = history[i + 1]; const diffTimeMs = curr.datetime.getTime() - prev.datetime.getTime(); const diffEnergy = this.computeDiffEnergy(mode, curr, prev); if (expDiffTimeMs !== null && estPower > 0) { energyToLog -= Math.max(estPower, minPower) * (Math.min(expDiffTimeMs, diffTimeMs) / 3600000); } if (expDiffTimeValueMs === undefined) { if (!prev.firstState) { expDiffTimeMs = diffTimeMs; } } else { expDiffTimeMs = expDiffTimeValueMs; } if (diffEnergy !== null) { energyToLog += diffEnergy; } if (expDiffTimeMs !== null) { estPower = energyToLog / (expDiffTimeMs / 3600000); estPower = Math.max(estPower, 0); } if (typeof expDiffTimeMarginFactor === 'number') { expDiffTimeMs = (expDiffTimeMs ?? 0) * (1 + expDiffTimeMarginFactor); } else if (typeof expDiffTimeMarginFactor === 'object') { expDiffTimeMs = (expDiffTimeMs ?? 0) + (expDiffTimeMarginFactor as number); } if (minPower !== null && estPower > 0) { estPower = Math.max(estPower, minPower); } } const lastEntry = history[history.length - 1]; if ( expDiffTimeMs !== null && now.getTime() > lastEntry.datetime.getTime() + expDiffTimeMs ) { estPower = 0; } if (minPower !== null && estPower > 0) { estPower = Math.max(estPower, minPower); } return estPower; } }