homebridge-daikin-oneplus
Version:
Control a Daikin One+ thermostat.
633 lines • 26.1 kB
JavaScript
import { hrtime } from 'process';
// After sending an update to the Daikin API it will return old data for up to 15 seconds, so we
// delay fetching data after an update by this amount. https://daikinone.com/openapi/documentation/
const DAIKIN_DEVICE_WRITE_DELAY_MS = 15 * 1000;
// User is not interacting with a HomeKit controller - background updates for automations
const DAIKIN_DEVICE_BACKGROUND_REFRESH_MS = 180 * 1000;
// User is interacting with a HomeKit controller - latest data needed
const DAIKIN_DEVICE_FOREGROUND_REFRESH_MS = 10 * 1000;
const DAIKIN_API_LOGIN_URL = 'https://api.daikinskyport.com/users/auth/login';
const DAIKIN_API_DEVICES_URL = 'https://api.daikinskyport.com/devices';
const DAIKIN_API_TOKEN_URL = 'https://api.daikinskyport.com/users/auth/token';
const getDeviceUrl = (deviceId) => {
return `https://api.daikinskyport.com/deviceData/${deviceId}`;
};
export class DaikinApi {
_token;
_tokenExpiration = new Date(0);
_devices = new Map(); // cache of all devices (thermostats) and their state
_isInitialized = false;
_listeners = new Map();
_lastUpdateTimeMs = -1;
_nextUpdateTimeMs = -1;
_noUpdateBeforeMs = 0;
_updateTimeout;
_lastWriteStartTimeMs = -1;
_lastWriteFinishTimeMs = -1;
_lastReadStartTimeMs = -1;
_lastReadFinishTimeMs = -1;
user;
password;
log;
logRaw;
// Pending thresholds per device for AUTO mode. HomeKit sends heat and cool thresholds
// separately, but the Daikin API requires both in a single request.
_pendingThresholds = new Map();
// Track emergency heat switch state per device. When the switch is ON, thermostat
// mode changes to HEAT should use EMERGENCY_HEAT instead.
_emergencyHeatEnabled = new Map();
constructor(user, password, log, logRaw) {
this.log = log;
this.user = user;
this.password = password;
this.logRaw = logRaw;
}
addListener(deviceId, listener) {
let deviceListeners = this._listeners.get(deviceId);
if (!deviceListeners) {
deviceListeners = new Set();
this._listeners.set(deviceId, deviceListeners);
}
deviceListeners.add(listener);
// Call listener immediately if data already exists (for initial state)
if (this._devices.get(deviceId)?.data) {
listener();
}
}
removeListener(deviceId, listener) {
this._listeners.get(deviceId)?.delete(listener);
}
notifyListeners(deviceId) {
const deviceListeners = this._listeners.get(deviceId);
if (deviceListeners) {
for (const listener of deviceListeners) {
listener();
}
}
}
async Initialize() {
await this.getToken();
if (!this._token) {
this.log.error('Unable to retrieve token.');
return;
}
await this.getDevices();
if (this._devices.size > 0) {
this.log.debug('Found %d devices: ', this._devices.size);
for (const device of this._devices.values()) {
this.log.debug('Device: %s', device.name);
}
}
else {
this.log.info('No devices found.');
return;
}
await this.getData();
this.log.debug('Loaded initial data.');
this._isInitialized = true;
}
isInitialized() {
return this._isInitialized;
}
async getData() {
this.log.debug('Getting data...');
this._lastReadStartTimeMs = this._monotonic_clock_ms();
this._lastReadFinishTimeMs = -1;
this.log.debug('GS: %d ; %d ; WT: %d ; %d', this._lastReadStartTimeMs, this._lastReadFinishTimeMs, this._lastWriteStartTimeMs, this._lastWriteFinishTimeMs);
for (const device of this._devices.values()) {
const data = await this.getDeviceData(device.id);
if (!data) {
this.log.error('Unable to retrieve data for %s [%s].', device.id, device.name);
continue;
}
this._updateCache(device.id, data);
this.log.debug('Notifying listeners for device %s', device.id);
this.notifyListeners(device.id);
}
this.log.debug('Updated data.');
this._lastReadFinishTimeMs = this._monotonic_clock_ms();
this.log.debug('GF: %d ; %d ; WT: %d ; %d', this._lastReadStartTimeMs, this._lastReadFinishTimeMs, this._lastWriteStartTimeMs, this._lastWriteFinishTimeMs);
this._nextUpdateTimeMs = -1;
this._scheduleUpdate();
}
updateNow() {
this._scheduleUpdate(undefined, true);
}
/**
* Schedules the next update. The scheduler has 2 modes.
*
* ASAP: Pull an update as soon as updates are neither blocked by a prior `blockUntilMs` value
* nor by the maximum refresh frequency `DAIKIN_DEVICE_FOREGROUND_REFRESH_MS`.
*
* Regular: Pull an update every `DAIKIN_DEVICE_BACKGROUND_REFRESH_MS`.
*
* @param blockUntilMs If given and > 0 then no updates are guaranteed to take place in the next `blockUntilMs` milliseconds.
* @param asap perform update as soon as allowed by DAIKIN_DEVICE_FOREGROUND_REFRESH_MS
*/
_scheduleUpdate(blockUntilMs, asap = false) {
if (asap) {
this._scheduleAsap(blockUntilMs);
}
else {
this._scheduleFuture(blockUntilMs);
}
}
_scheduleAsap(blockUntilMs) {
if (blockUntilMs) {
this.log.error('Ignoring blockUntilMs when scheduling ASAP');
}
const sinceLastUpdateMs = this._monotonic_clock_ms() - this._lastUpdateTimeMs;
const minUntilNextUpdateMs = this._noUpdateBeforeMs - this._monotonic_clock_ms();
if (sinceLastUpdateMs > DAIKIN_DEVICE_FOREGROUND_REFRESH_MS) {
if (minUntilNextUpdateMs <= 0) {
this.log.debug('Instant refresh now');
this._updateIn(0);
}
else {
this.log.debug('Instant refresh when update is allowed in %d', minUntilNextUpdateMs);
this._updateIn(minUntilNextUpdateMs);
}
}
else {
const sinceLastUpdateMs = this._monotonic_clock_ms() - this._lastUpdateTimeMs;
const updateInMs = DAIKIN_DEVICE_FOREGROUND_REFRESH_MS - sinceLastUpdateMs;
this.log.debug('Next allowed poll in %d', updateInMs);
this._updateIn(Math.max(minUntilNextUpdateMs, updateInMs));
}
}
_scheduleFuture(blockUntilMs) {
let nextUpdateInMs;
// set how long to wait to do an update (blockUntilMs)
// and when the next update should happen (nextUpdateInMs)
// as of 8/25/23, blockUntilMs will either be undefined or DAIKIN_DEVICE_WRITE_DELAY_MS only
if (!blockUntilMs) {
// just got data
blockUntilMs = DAIKIN_DEVICE_FOREGROUND_REFRESH_MS;
nextUpdateInMs = DAIKIN_DEVICE_BACKGROUND_REFRESH_MS;
}
else if (blockUntilMs < DAIKIN_DEVICE_FOREGROUND_REFRESH_MS) {
this.log.debug('BlockUntilMs too small %d is less than %d', blockUntilMs, DAIKIN_DEVICE_FOREGROUND_REFRESH_MS);
blockUntilMs = DAIKIN_DEVICE_FOREGROUND_REFRESH_MS;
nextUpdateInMs = DAIKIN_DEVICE_FOREGROUND_REFRESH_MS;
}
else if (blockUntilMs === DAIKIN_DEVICE_WRITE_DELAY_MS) {
nextUpdateInMs = DAIKIN_DEVICE_WRITE_DELAY_MS;
}
else {
nextUpdateInMs = DAIKIN_DEVICE_BACKGROUND_REFRESH_MS;
}
this._noUpdateBeforeMs = this._monotonic_clock_ms() + blockUntilMs;
const scheduledRunInMs = this._nextUpdateTimeMs - this._monotonic_clock_ms();
if (this._nextUpdateTimeMs === -1 || blockUntilMs > scheduledRunInMs) {
// if no run is scheduled at all OR if a run is scheduled for sooner than the desired minimum wait, push it into the future
this._updateIn(blockUntilMs > nextUpdateInMs ? blockUntilMs : nextUpdateInMs);
}
else {
// if the next update is already far enough in the future, nothing else to do
this.log.debug('Not rescheduling next update because %d is after %d', scheduledRunInMs, blockUntilMs);
}
}
_updateIn(nextUpdateMs) {
if (this._updateTimeout) {
clearTimeout(this._updateTimeout);
}
this._updateTimeout = setTimeout(() => {
void (async () => {
this._lastUpdateTimeMs = this._monotonic_clock_ms();
try {
await this.getData();
}
catch (err) {
this.logError('Error in scheduled update:', err);
}
})();
}, nextUpdateMs);
this._nextUpdateTimeMs = this._monotonic_clock_ms() + nextUpdateMs;
this.log.debug('Scheduled update in %d.', nextUpdateMs);
}
_monotonic_clock_ms() {
return Number(hrtime.bigint() / BigInt(1000000));
}
async getToken() {
this.log.debug('Getting token...');
try {
const response = await fetch(DAIKIN_API_LOGIN_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: this.user,
password: this.password,
}),
});
if (!response.ok) {
this.logError(`Request ${DAIKIN_API_LOGIN_URL} failed with status ${response.status} - ${response.statusText}.`, await response.text());
return;
}
const token = await response.json();
this.setToken(token);
}
catch (error) {
this.logError('Error getting token:', error);
}
}
setToken(token) {
this._token = token;
this._tokenExpiration = new Date();
const expSeconds = this._tokenExpiration.getSeconds() + this._token.accessTokenExpiresIn - (DAIKIN_DEVICE_BACKGROUND_REFRESH_MS / 1000) * 2;
//Set expiration a little early.
this._tokenExpiration.setSeconds(expSeconds);
}
async getDevices() {
const response = (await this.getRequest(DAIKIN_API_DEVICES_URL)) ?? [];
this._devices = new Map(response.map(d => [d.id, d]));
return this._devices;
}
async getDeviceData(deviceId) {
return await this.getRequest(getDeviceUrl(deviceId));
}
async refreshToken() {
if (typeof this._token === 'undefined' || typeof this._token.refreshToken === 'undefined' || !this._token.refreshToken) {
this.log.debug('Cannot refresh token. Getting new token.');
return this.getToken();
}
try {
const response = await fetch(DAIKIN_API_TOKEN_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: this.user,
refreshToken: this._token.refreshToken,
}),
});
if (!response.ok) {
this.logError(`Request ${DAIKIN_API_TOKEN_URL} failed with status ${response.status} - ${response.statusText}.`, await response.text());
return;
}
const token = await response.json();
this.setToken(token);
}
catch (error) {
this.logError('Error refreshing token:', error);
}
}
async getRequest(uri) {
if (new Date() >= this._tokenExpiration) {
await this.refreshToken();
}
if (!this._token) {
this.log.error('No token for request: %s', uri);
return undefined;
}
try {
const response = await fetch(uri, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${this._token.accessToken}`,
},
});
if (!response.ok) {
this.logError(`Request ${uri} failed with status ${response.status} - ${response.statusText}.`, await response.text());
return undefined;
}
return response.json();
}
catch (error) {
this.logError(`Error with getRequest: ${uri}`, error);
return undefined;
}
}
getDeviceList() {
return [...this._devices.values()];
}
getCurrentStatus(deviceId) {
return this._devices.get(deviceId)?.data?.equipmentStatus ?? 5 /* EquipmentStatus.IDLE */;
}
getCurrentTemp(deviceId) {
return this._devices.get(deviceId)?.data?.tempIndoor ?? -270;
}
getOutdoorTemp(deviceId) {
return this._devices.get(deviceId)?.data?.tempOutdoor ?? -270;
}
getTargetState(deviceId) {
return this._devices.get(deviceId)?.data?.mode ?? 0 /* ThermostatMode.OFF */;
}
getOneCleanFanActive(deviceId) {
return this._devices.get(deviceId)?.data?.oneCleanFanActive ?? false;
}
getCirculateAirFanActive(deviceId) {
const fanCirculate = this._devices.get(deviceId)?.data?.fanCirculate;
return fanCirculate !== undefined && fanCirculate !== 0 /* FanCirculateMode.OFF */;
}
getCirculateAirFanSpeed(deviceId) {
return this._devices.get(deviceId)?.data?.fanCirculateSpeed ?? 0;
}
getTargetTemp(deviceId) {
const data = this._devices.get(deviceId)?.data;
if (!data)
return -270;
switch (data.mode) {
case 1 /* ThermostatMode.HEAT */:
case 4 /* ThermostatMode.EMERGENCY_HEAT */:
case 3 /* ThermostatMode.AUTO */:
return data.hspActive;
case 2 /* ThermostatMode.COOL */:
default:
return data.cspActive;
}
}
heatingThresholdTemperature(deviceId) {
return this._devices.get(deviceId)?.data?.hspActive ?? -270;
}
coolingThresholdTemperature(deviceId) {
return this._devices.get(deviceId)?.data?.cspActive ?? -270;
}
getCurrentHumidity(deviceId) {
return this._devices.get(deviceId)?.data?.humIndoor ?? 0;
}
getOutdoorHumidity(deviceId) {
return this._devices.get(deviceId)?.data?.humOutdoor ?? 0;
}
getTargetHumidity(deviceId) {
return this._devices.get(deviceId)?.data?.humSP ?? 0;
}
getAirQualityLevel(deviceId, forIndoor) {
const data = this._devices.get(deviceId)?.data;
if (!data)
return 0 /* AirQualityLevel.GOOD */;
return forIndoor ? data.aqIndoorLevel : data.aqOutdoorLevel;
}
getOzone(deviceId, forIndoor) {
if (forIndoor)
return 0;
return this._devices.get(deviceId)?.data?.aqOutdoorOzone ?? 0;
}
getAirQualityValue(deviceId, forIndoor) {
const data = this._devices.get(deviceId)?.data;
if (!data)
return 0;
return forIndoor ? data.aqIndoorValue : data.aqOutdoorValue;
}
getPM2_5Density(deviceId, forIndoor) {
const data = this._devices.get(deviceId)?.data;
if (!data)
return 0;
return forIndoor ? data.aqIndoorParticlesValue : data.aqOutdoorParticles;
}
getVocDensity(deviceId, forIndoor) {
if (!forIndoor)
return 0;
return this._devices.get(deviceId)?.data?.aqIndoorVOCValue ?? 0;
}
getDisplayUnits(deviceId) {
return this._devices.get(deviceId)?.data?.units ?? 0 /* TemperatureUnit.FAHRENHEIT */;
}
getScheduleState(deviceId) {
const data = this._devices.get(deviceId)?.data;
if (!data)
return false;
return data.schedOverride === 0 && data.schedEnabled && !data.geofencingAway;
}
getAwayState(deviceId) {
return this._devices.get(deviceId)?.data?.geofencingAway ?? false;
}
setEmergencyHeatEnabled(deviceId, enabled) {
this._emergencyHeatEnabled.set(deviceId, enabled);
}
isEmergencyHeatEnabled(deviceId) {
return this._emergencyHeatEnabled.get(deviceId) ?? false;
}
async setTargetTemps(deviceId, targetTemp, heatThreshold, coolThreshold) {
const deviceData = this._devices.get(deviceId)?.data;
if (!deviceData) {
this.log.error('Cannot set target temps - no data for device:', deviceId);
return false;
}
// apiData: fields to send to API (only writable fields)
// cacheUpdate: fields to update in local cache (includes read-only derived fields for immediate UI feedback)
let apiData;
let cacheUpdate;
// Only send the request if the request provides a value pertinent to the current state/mode.
switch (deviceData.mode) {
case 1 /* ThermostatMode.HEAT */:
case 4 /* ThermostatMode.EMERGENCY_HEAT */:
if (!targetTemp) {
return true;
}
apiData = { hspHome: targetTemp };
cacheUpdate = { hspHome: targetTemp, hspActive: targetTemp };
break;
case 2 /* ThermostatMode.COOL */:
if (!targetTemp) {
return true;
}
apiData = { cspHome: targetTemp };
cacheUpdate = { cspHome: targetTemp, cspActive: targetTemp };
break;
case 0 /* ThermostatMode.OFF */:
// Do nothing when off
return true;
case 3 /* ThermostatMode.AUTO */: {
// Disregard setting Target Temp when in auto/off
if (targetTemp) {
return true;
}
// Get or create pending thresholds for this device
let pending = this._pendingThresholds.get(deviceId);
if (!pending) {
pending = {};
this._pendingThresholds.set(deviceId, pending);
}
// Accumulate thresholds - HomeKit sends them separately
if (coolThreshold !== undefined) {
pending.cool = coolThreshold;
}
if (heatThreshold !== undefined) {
pending.heat = heatThreshold;
}
// Wait until we have both thresholds before sending to API
if (pending.heat === undefined || pending.cool === undefined) {
return true;
}
apiData = {
hspHome: pending.heat,
cspHome: pending.cool,
};
cacheUpdate = {
hspHome: pending.heat,
hspActive: pending.heat,
cspHome: pending.cool,
cspActive: pending.cool,
};
// Reset pending thresholds for this device
this._pendingThresholds.delete(deviceId);
break;
}
default:
this.log.info('Device is in an unknown state: %s. Unable to set target temp. (%s)', deviceData.mode, deviceId);
return false;
}
if (deviceData.schedEnabled) {
apiData.schedOverride = 1;
cacheUpdate.schedOverride = 1;
}
const success = await this.putRequest(deviceId, apiData, 'setTargetTemps', 'Error updating target temp:');
if (success) {
// Update cache with both API fields and derived fields for immediate UI feedback
this._updateCache(deviceId, cacheUpdate);
}
return success;
}
async setTargetState(deviceId, requestedState) {
const requestedData = {
mode: requestedState,
};
// Update cache immediately so subsequent commands (e.g., setTargetTemps from a scene) see the new mode
this._updateCache(deviceId, { mode: requestedState });
return this.putRequest(deviceId, requestedData, 'setTargetState', 'Error updating target state:');
}
async setOneCleanFanActive(deviceId, requestedState) {
const requestedData = {
oneCleanFanActive: requestedState,
};
return this.putRequest(deviceId, requestedData, 'setOneCleanFanActive', 'Error updating OneClean fan:');
}
async setCirculateAirFanActive(deviceId, requestedState) {
const requestedData = {
fanCirculate: requestedState ? 1 : 0,
};
return this.putRequest(deviceId, requestedData, 'setCirculateAirFanActive', 'Error updating Circulate Air fan:');
}
async setCirculateAirFanSpeed(deviceId, requestedSpeed) {
let requestedData;
if (requestedSpeed === -1) {
requestedData = {
fanCirculate: 0,
fanCirculateSpeed: 1,
};
}
else {
requestedData = {
fanCirculateSpeed: requestedSpeed,
};
}
return this.putRequest(deviceId, requestedData, 'setCirculateAirFanSpeed', 'Error updating Circulate Air fan and speed:');
}
async setDisplayUnits(deviceId, requestedUnits) {
const requestedData = {
units: requestedUnits,
};
return this.putRequest(deviceId, requestedData, 'setDisplayUnits', 'Error updating display units:');
}
async setTargetHumidity(deviceId, requestedHumidity) {
const requestedData = {
humSP: requestedHumidity,
};
return this.putRequest(deviceId, requestedData, 'setTargetHumidity', 'Error updating target humidity:');
}
async setScheduleState(deviceId, requestedState) {
let requestedData;
// when enabling the schedule state, a schedule must exist.
if (requestedState) {
requestedData = {
geofencingAway: false,
schedOverride: 0,
schedEnabled: true,
};
}
else {
requestedData = {
schedEnabled: false,
};
}
this.log.debug('Schedule for %s: %s: %s', deviceId, requestedState, requestedData);
return this.putRequest(deviceId, requestedData, 'setScheduleState', 'Error updating schedule state:');
}
async setAwayState(deviceId, requestedState, enableSchedule) {
let requestedData;
if (requestedState) {
// when enabling the away state, the schedule (if it exists) is automatically paused.
requestedData = {
geofencingAway: true,
};
}
else {
if (enableSchedule) {
requestedData = {
geofencingAway: false,
schedEnabled: true,
};
}
else {
requestedData = {
geofencingAway: false,
};
}
}
return this.putRequest(deviceId, requestedData, 'setAwayState', 'Error updating away state:');
}
async putRequest(deviceId, requestData, caller, errorHeader) {
this.log.debug('Writing data: %s-> device: %s; requestData: %s', caller, deviceId, JSON.stringify(requestData));
this._lastWriteStartTimeMs = this._monotonic_clock_ms();
this._lastWriteFinishTimeMs = -1;
this.log.debug('WS: %d ; %d ; GT: %d ; %d', this._lastWriteStartTimeMs, this._lastWriteFinishTimeMs, this._lastReadStartTimeMs, this._lastReadFinishTimeMs);
if (!this._token) {
this.log.error('No token for write request: %s', deviceId);
return false;
}
try {
const uri = getDeviceUrl(deviceId);
const response = await fetch(uri, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${this._token.accessToken}`,
},
body: JSON.stringify(requestData),
});
if (!response.ok) {
this.logError(`Request ${uri} failed with status ${response.status} - ${response.statusText}.`, await response.text());
this._lastWriteFinishTimeMs = this._monotonic_clock_ms();
return false;
}
this.log.debug('%s-> device: %s; response: %s', caller, deviceId, JSON.stringify(response.json()));
this._lastWriteFinishTimeMs = this._monotonic_clock_ms();
this.log.debug('WF: %d ; %d ; GT: %d ; %d', this._lastWriteStartTimeMs, this._lastWriteFinishTimeMs, this._lastReadStartTimeMs, this._lastReadFinishTimeMs);
this._updateCache(deviceId, requestData);
this._scheduleUpdate(DAIKIN_DEVICE_WRITE_DELAY_MS);
return true;
}
catch (error) {
this.logError(`${errorHeader} Device: ${deviceId}:`, error);
this._lastWriteFinishTimeMs = this._monotonic_clock_ms();
return false;
}
}
_updateCache(deviceId, partialUpdate) {
const cachedDevice = this._devices.get(deviceId);
if (!cachedDevice) {
this.log.error("Cache update for device that doesn't exist:", deviceId);
return;
}
cachedDevice.data = {
...(cachedDevice.data ?? {}),
...partialUpdate,
};
if (this.logRaw) {
this.log.debug('Updated cache for %s: %s', deviceId, JSON.stringify(partialUpdate));
}
this.log.debug('Updated cache for %s', deviceId);
}
logError(message, error) {
this.log.error(message);
this.log.error(String(error));
return false;
}
}
//# sourceMappingURL=daikinapi.js.map