homebridge-vicare
Version:
Homebridge plugin for Viessmann ViCare
366 lines • 17.2 kB
JavaScript
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