UNPKG

homebridge-vicare

Version:
366 lines 17.2 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import crypto from 'node:crypto'; import { promises as fs } from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { internalIpV4 } from 'internal-ip'; import { UUIDGen, Accessory, Service, Characteristic } from './index.js'; import { ViCareThermostatAccessory } from './ViCareThermostatAccessory.js'; import { RequestService } from './RequestService.js'; export class ViCareThermostatPlatform { constructor(log, config, api) { this.log = log; this.config = config; this.api = api; this.clientId = config.clientId; this.apiEndpoint = config.apiEndpoint; this.devices = config.devices; this.accessories = []; this.codeVerifier = this.generateCodeVerifier(); this.codeChallenge = this.generateCodeChallenge(this.codeVerifier); this.localStoragePath = path.join(api.user.storagePath(), 'homebridge-vicare-settings.json'); this.requestService = new RequestService(this.log, this.clientId); this.log.debug('Loaded config', config); this.api.on('didFinishLaunching', () => __awaiter(this, void 0, void 0, function* () { var _a; const storage = yield this.loadLocalStorage(); if (storage) { this.localStorage = storage; } try { if ((_a = this.localStorage) === null || _a === void 0 ? void 0 : _a.refreshToken) { this.log('Found refresh token in storage file 🙌'); this.requestService.refreshToken = this.localStorage.refreshToken; yield this.requestService.refreshAuth(); } else { throw new Error('No token found'); } } catch (error) { this.log.warn('Refresh token invalid:', error); yield this.startAuth(config.hostIp); } try { const { installationId, gatewaySerial } = yield this.retrieveIds(); this.log('Retrieved installation and gateway IDs.'); this.installationId = installationId; this.gatewaySerial = gatewaySerial; for (const deviceConfig of this.devices) { this.addAccessory(deviceConfig); } yield this.retrieveSmartComponents(); } catch (error) { this.log.error('Error retrieving installation or gateway IDs:', error); throw error; } this.log('All set up! ✨'); })); } startAuth(hostIp) { return __awaiter(this, void 0, void 0, function* () { this.log('Starting authentication process...'); this.hostIp = hostIp || (yield internalIpV4()); this.redirectUri = `http://${this.hostIp}:4200`; this.log.debug(`Using redirect URI: ${this.redirectUri}`); try { const { access_token, refresh_token } = yield this.authenticate(); this.requestService.accessToken = access_token; this.requestService.refreshToken = refresh_token; yield this.saveLocalStorage(Object.assign(Object.assign({}, this.localStorage), { refreshToken: refresh_token })); } catch (error) { this.log.error('Error during authentication:', error); throw error; } if (this.requestService.accessToken) { this.log('Authentication successful, received access token.'); } else { this.log.error('Authentication did not succeed, received no access token.'); return; } }); } configureAccessory(accessory) { this.accessories.push(accessory); } saveLocalStorage(config) { return __awaiter(this, void 0, void 0, function* () { this.log.debug('Saving local storage ...'); try { yield fs.writeFile(this.localStoragePath, JSON.stringify(config), 'utf-8'); } catch (error) { this.log.warn('Error while saving local storage:', error); return; } this.log.debug('Successfully saved local storage.'); }); } loadLocalStorage() { return __awaiter(this, void 0, void 0, function* () { this.log.debug('Loading local storage ...'); let storageFileRaw; let storage; try { storageFileRaw = yield fs.readFile(this.localStoragePath, 'utf-8'); } catch (_a) { this.log.debug('No storage file found, creating ...'); yield fs.writeFile(this.localStoragePath, '{}', 'utf-8'); } if (storageFileRaw) { try { storage = JSON.parse(storageFileRaw); } catch (_b) { this.log.warn(`Storage file "${this.localStoragePath}" is not valid JSON`); } } else { this.log.debug('No storage file found, creating ...'); yield fs.writeFile(this.localStoragePath, '{}', 'utf-8'); } return storage || null; }); } generateCodeVerifier() { return crypto.randomBytes(32).toString('base64url'); } generateCodeChallenge(codeVerifier) { return crypto.createHash('sha256').update(codeVerifier).digest('base64url'); } authenticate() { if (!this.redirectUri) { throw new Error('Redirect URI is not set. Please call startAuth() first.'); } const params = new URLSearchParams(); params.set('client_id', this.clientId); params.set('redirect_uri', this.redirectUri); params.set('scope', 'IoT User offline_access'); params.set('response_type', 'code'); params.set('code_challenge_method', 'S256'); params.set('code_challenge', this.codeChallenge); const authUrl = `https://iam.viessmann-climatesolutions.com/idp/v3/authorize?${params.toString()}`; this.log(`Click this link for authentication: ${authUrl}`); return this.getCodeViaServer(); } getCodeViaServer() { return new Promise((resolve, reject) => { this.server = http .createServer((req, res) => { if (!req.url || !req.headers.host) { this.log.error('Invalid request received, missing URL or host header.'); res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Invalid request.'); return; } const url = new URL(req.url, `http://${req.headers.host}`); const authCode = url.searchParams.get('code'); if (authCode) { this.log.debug('Received authorization code:', authCode); res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('Authorization successful. You can close this window.'); this.exchangeCodeForToken(authCode) .then(auth => { var _a; (_a = this.server) === null || _a === void 0 ? void 0 : _a.close(); resolve(auth); }) .catch(reject); } else { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Authorization code not found.'); } }) .listen(4200, this.hostIp, () => { this.log.debug(`Server is listening on ${this.hostIp}:4200`); }); }); } exchangeCodeForToken(authCode) { return __awaiter(this, void 0, void 0, function* () { if (!this.redirectUri) { throw new Error('Redirect URI is not set. Please call startAuth() first.'); } const tokenUrl = 'https://iam.viessmann-climatesolutions.com/idp/v3/token'; const params = new URLSearchParams(); params.set('client_id', this.clientId); params.set('redirect_uri', this.redirectUri); params.set('grant_type', 'authorization_code'); params.set('code_verifier', this.codeVerifier); params.set('code', authCode); this.log.debug('Exchanging authorization code for access token...'); try { const response = yield this.requestService.request(tokenUrl, 'post', { body: params, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); const tokenResponse = (yield response.json()); if (!response.ok) { throw new Error(JSON.stringify(tokenResponse, null, 2)); } this.log.debug('Successfully exchanged code for access token.'); return tokenResponse; } catch (error) { this.log.error('Error exchanging code for token:', error); throw error; } }); } retrieveIds() { return __awaiter(this, void 0, void 0, function* () { this.log.debug('Retrieving installation IDs...'); const url = `${this.apiEndpoint}/equipment/installations`; let installationId; try { const response = yield this.requestService.authorizedRequest(url); const body = (yield response.json()); if (!response.ok) { return yield this.requestService.checkForTokenExpiration(body, url); } this.log('Successfully retrieved installations.'); this.log.debug(JSON.stringify(body, null, 2)); const [installation] = body.data; installationId = installation.id; } catch (error) { this.log.error('Error retrieving installations:', error); throw error; } this.log.debug('Retrieving gateway IDs...'); try { const url = `${this.apiEndpoint}/equipment/installations/${installationId}/gateways`; const response = yield this.requestService.authorizedRequest(url, 'get'); const body = (yield response.json()); if (!response.ok) { return yield this.requestService.checkForTokenExpiration(body, url); } this.log('Successfully retrieved gateways.'); this.log.debug(JSON.stringify(body, null, 2)); if (!body.data || body.data.length === 0) { this.log.error('No gateway data available.'); throw new Error('No gateway data available.'); } const [gateway] = body.data; const gatewaySerial = gateway.serial; return { installationId, gatewaySerial }; } catch (error) { this.log.error('Error retrieving gateways:', error); throw error; } }); } retrieveSmartComponents() { return __awaiter(this, void 0, void 0, function* () { this.log.debug('Retrieving smart components...'); const url = `${this.apiEndpoint}/equipment/installations/${this.installationId}/smartComponents`; try { const response = yield this.requestService.authorizedRequest(url, 'get'); const body = (yield response.json()); if (!response.ok) { return yield this.requestService.checkForTokenExpiration(body, url); } this.log.debug('Successfully retrieved smart components.'); this.log.debug(JSON.stringify(body, null, 2)); for (const component of body.data) { this.log.debug(`Component ID: ${component.id}, Name: ${component.name}, Selected: ${component.selected}, Deleted: ${component.deleted}`); } } catch (error) { this.log.error('Error retrieving smart components:', error); throw error; } }); } selectSmartComponents(componentIds) { return __awaiter(this, void 0, void 0, function* () { this.log.debug('Selecting smart components...'); const url = `${this.apiEndpoint}/equipment/installations/${this.installationId}/smartComponents`; try { const response = yield this.requestService.authorizedRequest(url, 'put', { body: JSON.stringify({ selected: componentIds }), headers: { 'Content-Type': 'application/json', }, }); const body = (yield response.json()); if (!response.ok) { return yield this.requestService.checkForTokenExpiration(body, url); } this.log('Successfully selected smart components:', body); return { result: body }; } catch (error) { this.log.error('Error selecting smart components:', error); throw error; } }); } addAccessory(deviceConfig) { var _a; if (!deviceConfig.name) { this.log.error('Device name is not set, skipping accessory creation.'); return; } if (!this.installationId || !this.gatewaySerial) { this.log.error('Installation ID or gateway serial is not set, cannot add accessory.'); return; } const uuid = UUIDGen.generate(deviceConfig.name); let accessory = this.accessories.find(acc => acc.UUID === uuid); if (!accessory) { accessory = new Accessory(deviceConfig.name, uuid); this.api.registerPlatformAccessories('homebridge-vicare', 'ViCareThermostatPlatform', [accessory]); this.accessories.push(accessory); this.log.debug(`Added new accessory: "${deviceConfig.name}"`); } else { this.log.debug(`Loaded existing accessory from cache: "${deviceConfig.name}"`); } accessory.context.deviceConfig = deviceConfig; (_a = accessory .getService(Service.AccessoryInformation)) === null || _a === void 0 ? void 0 : _a.setCharacteristic(Characteristic.Manufacturer, 'Viessmann').setCharacteristic(Characteristic.Model, 'ViCare').setCharacteristic(Characteristic.SerialNumber, 'Default-Serial'); const vicareAccessory = new ViCareThermostatAccessory(this.log, this.requestService, this.apiEndpoint, this.installationId.toString(), this.gatewaySerial, deviceConfig); const newServices = vicareAccessory.getServices(); for (const oldService of accessory.services) { if (oldService.UUID === Service.AccessoryInformation.UUID) { continue; } const stillNeeded = newServices.some(s => s.UUID === oldService.UUID && s.subtype === oldService.subtype); if (!stillNeeded) { this.log.debug(`Removing obsolete service from "${deviceConfig.name}": UUID=${oldService.UUID}, Subtype=${oldService.subtype}`); accessory.removeService(oldService); } } for (const service of newServices) { if (!service.subtype) { this.log.error(`Subtype not set, cannot add service for accessory "${deviceConfig.name}".`); continue; } const existingService = accessory.getServiceById(service.UUID, service.subtype); if (!existingService) { this.log.debug(`Adding new service to "${deviceConfig.name}": UUID=${service.UUID}, Subtype=${service.subtype}`); accessory.addService(service); } } this.api.updatePlatformAccessories([accessory]); } } //# sourceMappingURL=ViCareThermostatPlatform.js.map