UNPKG

thermostat-pi-dht

Version:

A Node.js framework (standalone app + lib) to control a heating system with a Raspberry Pi using DHT11 or DHT22/AM2302 sensors and GPIO actuators.

292 lines 12.3 kB
#!/usr/bin/env node "use strict"; // Project: thermostat-pi-dht // File: thermostat-pi-dht.ts // // Copyright 2021 Henning Kerstan // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const process_1 = require("process"); const fs_1 = __importDefault(require("fs")); const Thermostat_1 = require("./Thermostat"); const http = __importStar(require("http")); const heartbeat_led_pi_1 = require("@henningkerstan/heartbeat-led-pi"); const crypto_1 = require("crypto"); const hmac_authenticated_payload_1 = require("@henningkerstan/hmac-authenticated-payload"); let host; let port; let hmacKey = undefined; let heartbeatLED; const thermostats = []; let server = undefined; let configFile; function readVersionFromPackageJSON() { // read version number from package json const packageJsonFile = __dirname + '/../package.json'; if (fs_1.default.existsSync(packageJsonFile)) { try { const fileContent = fs_1.default.readFileSync(packageJsonFile, { encoding: 'utf8' }); const packageJson = JSON.parse(fileContent); if (packageJson.version) { return packageJson.version; } } catch (error) { // nothing to be done } } return '<UNKNOWN>'; } function handleHTTPRequest(req, res) { const url = new URL(req.url, `http://${req.headers.host}`); // only serve two endpoints if (url.pathname !== '/data.json' && url.pathname !== '/config.json') { return; } res.setHeader('Content-Type', 'application/json'); res.writeHead(200); // on both endpoints GETting is ok if (req.method === 'GET') { switch (url.pathname) { case '/data.json': res.end(JSON.stringify(hmac_authenticated_payload_1.HMACAuthenticatedPayload.create(hmacKey, thermostats))); break; case '/config.json': res.end(JSON.stringify(hmac_authenticated_payload_1.HMACAuthenticatedPayload.create(hmacKey, currentConfiguration()))); } } // on both endpoints POSTing is ok; body must contain ThermostatSetpoint encapsulated in a valid HMACAuthenticatedData if (req.method === 'POST') { let body = ''; req.on('data', (data) => { body += data; }); req.on('end', () => { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const bodyJSON = JSON.parse(body); // parse data const ad = new hmac_authenticated_payload_1.HMACAuthenticatedPayload(bodyJSON.payload, bodyJSON.nonce, bodyJSON.hmac); // check nonce is within 2s of my time const timestamp = Date.now(); const deviation = Math.abs(timestamp - parseInt(ad.nonce)); if (deviation > 2000) { throw new Error('Invalid nonce: Deviation from current timestamp exceeds 2s.'); } // check HMAC if (!ad.validate(hmacKey)) { throw new Error('HMAC invalid.'); } // payload must be A ThermostatSetpoint const thermostatSetpoint = ad.payload; if (!thermostatSetpoint.name || !thermostatSetpoint.setpoint) { throw new Error('Not a valid ThermostatSetpoint'); } // find matching thermostat and adjust setpoint, if found let succeeded = false; for (const t of thermostats) { if (t.name === thermostatSetpoint.name) { t.setpoint = thermostatSetpoint.setpoint; // pretty print the JSON to the config file using 2 spaces fs_1.default.writeFileSync(configFile, JSON.stringify(currentConfiguration(true), null, 2)); succeeded = true; break; } } if (!succeeded) { throw new Error('Thermostat not found.'); } } catch (error) { console.warn('Invalid POST request.'); console.warn(error); } switch (url.pathname) { case '/data.json': res.end(JSON.stringify(hmac_authenticated_payload_1.HMACAuthenticatedPayload.create(hmacKey, thermostats))); break; case '/config.json': res.end(JSON.stringify(hmac_authenticated_payload_1.HMACAuthenticatedPayload.create(hmacKey, currentConfiguration()))); } }); return; } } function init() { const version = readVersionFromPackageJSON(); console.log('thermostat-pi-dht v' + version); if (!loadConfiguration()) { (0, process_1.exit)(-1); } // ensure proper shutdown on signals process.on('SIGINT', () => { void shutdown('SIGINT'); }); process.on('SIGQUIT', () => { void shutdown('SIGQUIT'); }); process.on('SIGTERM', () => { void shutdown('SIGTERM'); }); process.on('SIGHUP', () => { void shutdown('SIGHUP'); }); // create the server with two endpoints 'data.json' and 'config.json' server = http.createServer(handleHTTPRequest); server.listen(port, host, () => { console.log(`server is running on http://${host}:${port}`); console.log("- sensor is available on 'data.json' endpoint"); console.log("- configuration data is available on 'config.json' endpoint"); console.log('STARTUP COMPLETE'); }); thermostats.forEach((thermostat) => thermostat.activate()); if (heartbeatLED) { heartbeatLED.start(); } } function loadConfiguration() { const args = process.argv.slice(2); if (args.length > 1) { console.log('Usage: "thermostat-pi-dht" or "thermostat-pi-dht /path/to/config.json".\n\nIf called without path, the "config.json" must be placed in "/etc/thermostat-pi-dht/config.json". Note that this program requires super user privileges to access the GPIOs.'); (0, process_1.exit)(-1); } configFile = args.length === 1 ? args[0] : '/etc/thermostat-pi-dht/config.json'; if (!fs_1.default.existsSync(configFile)) { console.error(`ERROR: Could not find configuration file "${configFile}".`); return false; } const fileContent = fs_1.default.readFileSync(configFile, { encoding: 'utf8' }); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const config = JSON.parse(fileContent); if (config.sensorWarmUpTime) { Thermostat_1.Thermostat.samplingInterval = config.samplingInterval; console.log('custom sampling interval: ' + Thermostat_1.Thermostat.samplingInterval.toString() + 's'); } else { console.log('default sampling interval: ' + Thermostat_1.Thermostat.samplingInterval.toString() + 's'); } if (config.sensorWarmUpTime) { Thermostat_1.Thermostat.sensorWarmUpTime = config.sensorWarmUpTime; console.log('custom sensor warmup: ' + Thermostat_1.Thermostat.sensorWarmUpTime.toString() + 's'); } else { console.log('default sensor warmup: ' + Thermostat_1.Thermostat.sensorWarmUpTime.toString() + 's'); } if (config.timeoutSeconds) { Thermostat_1.Thermostat.timeout = config.timeoutSeconds; console.log('custom (global) sensor timeout: ' + Thermostat_1.Thermostat.timeout.toString() + 's'); } else { console.log('default (global) sensor timeout: ' + Thermostat_1.Thermostat.timeout.toString() + 's'); } if (config.heartbeatPin) { heartbeatLED = new heartbeat_led_pi_1.HeartbeatLED(config.heartbeatPin); console.log('heartbeat LED connected to pin: ' + config.heartbeatPin.toString()); } else { console.log('no heartbeat LED connected'); } if (config.sensorPowerPin) { Thermostat_1.Thermostat.sensorPowerPin = config.sensorPowerPin; console.log('sensor power control connected to pin: ' + Thermostat_1.Thermostat.sensorPowerPin.toString()); } else { console.log('no sensor power control (sensor power always on)'); } console.log('loading thermostats'); for (const r of config.thermostats) { if (!r.name) { console.warn('WARNING: skipping thermostat without name: ' + JSON.stringify(r)); continue; } thermostats.push(new Thermostat_1.Thermostat(r)); console.log(' ' + JSON.stringify(r)); } if (thermostats.length < 1) { console.error('ERROR: No valid thermostats found.'); return false; } host = config.host ? config.host : 'localhost'; port = config.port ? config.port : 8000; // todo: ensure that hmacKey, if present, is sufficiently large if (!config.hmacKey) { config.hmacKey = (0, crypto_1.randomBytes)(64).toString('base64'); // pretty print the JSON to the config file using 2 spaces fs_1.default.writeFileSync(configFile, JSON.stringify(config, null, 2)); console.log('No HMAC key was found in configuration. A new (random) key has been added to the configuration.'); } hmacKey = Buffer.from(config.hmacKey, 'base64'); console.log('listening on: ' + host + ':' + port.toString()); return true; } /** Return the current configuration (default without the HMAC key). */ function currentConfiguration(withHMACkey = false) { const thermostatConfigurations = []; thermostats.forEach((t) => { thermostatConfigurations.push(t.configurationToJSON()); }); return { host: host, port: port, sensorWarmUpTime: Thermostat_1.Thermostat.sensorWarmUpTime, samplingInterval: Thermostat_1.Thermostat.samplingInterval, sensorPowerPin: Thermostat_1.Thermostat.sensorPowerPin, heartbeatPin: heartbeatLED ? heartbeatLED.pin : undefined, timeoutSeconds: Thermostat_1.Thermostat.timeout, thermostats: thermostatConfigurations, hmacKey: withHMACkey ? hmacKey.toString('base64') : undefined, }; } function shutdown(signalName) { console.log('Inititiating shutdown due to received "' + signalName + '" signal'); Thermostat_1.Thermostat.deactivateAll(); if (heartbeatLED) { heartbeatLED.stop(); } process.exit(); } init(); //# sourceMappingURL=thermostat-pi-dht.js.map