From b24d7f944e70a2f874537bce4af1e9936d219ef8 Mon Sep 17 00:00:00 2001 From: Zefiro Date: Wed, 12 Apr 2023 02:36:31 +0200 Subject: [PATCH] added ZWave things --- POS.js | 2 +- grag.js | 9 ++-- medusa.js | 50 +++++++++++++++-------- mpd.js | 4 +- public/grag3.html | 3 +- scenario.js | 1 + things.js | 102 ++++++++++++++++++++++++++++++++++++++++++++-- zwave.js | 82 +++++++++++++++++++++++++++++++++++++ zwave.mjs | 48 ---------------------- 9 files changed, 223 insertions(+), 78 deletions(-) create mode 100755 zwave.js delete mode 100755 zwave.mjs diff --git a/POS.js b/POS.js index 8c40977..5646597 100755 --- a/POS.js +++ b/POS.js @@ -13,7 +13,7 @@ const fs = require('fs') const fsa = fs.promises - module.exports = function(god, loggerName = 'POS') { +module.exports = function(god, loggerName = 'POS') { var self = { controller: {}, diff --git a/grag.js b/grag.js index 0ceb23d..ba24907 100755 --- a/grag.js +++ b/grag.js @@ -24,7 +24,6 @@ const { exec } = require("child_process") const socketIo = require('socket.io') const dns = require('dns') const moment = require('moment') -const jsonc = require('./jsonc')() const yaml = require('js-yaml') const util = require('util') const exec2 = util.promisify(require('child_process').exec); @@ -34,9 +33,9 @@ const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch console.log('Press +C to exit.') -let sConfigFile = 'prod.json' +let sConfigFile = 'prod.yaml' console.log("Loading config " + sConfigFile) -let config = yaml.load(fs.readFileSync(path.resolve(__dirname, 'config', 'prod.yaml'), 'utf8')) +let config = yaml.load(fs.readFileSync(path.resolve(__dirname, 'config', sConfigFile), 'utf8')) var isTerminated = false async function terminate(errlevel) { @@ -194,11 +193,11 @@ god.mqtt = mqtt // initialization race condition, hope for the best... (later code parts could already access mpd1/2 before the async func finishes) var mpd1 -(async () => { mpd1 = await require('./mpd')(god, 'localhost', 'mpd1') })() +(async () => { mpd1 = await require('./mpd')(god, 'localhost', 'mpd1', 'grag-mpd1') })() var mpd2 -(async () => { mpd2 = await require('./mpd')(god, 'grag-hoardpi', 'mpd2') })() +(async () => { mpd2 = await require('./mpd')(god, 'grag-hoardpi', 'mpd2', 'grag-mpd2') })() const web = require('./web')(god, 'web') diff --git a/medusa.js b/medusa.js index 7878c88..376c816 100755 --- a/medusa.js +++ b/medusa.js @@ -39,7 +39,7 @@ const { exec } = require("child_process") const socketIo = require('socket.io') const dns = require('dns') const moment = require('moment') -const jsonc = require('./jsonc')() +const yaml = require('js-yaml') const util = require('util') const exec2 = util.promisify(require('child_process').exec); @@ -48,10 +48,10 @@ const fetch = (...args) => import('node-fetch').then(({default: fetch}) => fetch console.log('Press +C to exit.') -let sConfigFile = 'prod.json' +let sConfigFile = 'prod.yaml' console.log("Loading config " + sConfigFile) -let configBuffer = fs.readFileSync(path.resolve(__dirname, 'config', sConfigFile), 'utf-8') -let config = jsonc.parse(configBuffer) +let config = yaml.load(fs.readFileSync(path.resolve(__dirname, 'config', sConfigFile), 'utf8')) + var isTerminated = false async function terminate(errlevel) { @@ -203,12 +203,19 @@ function addNamedLogger(name, level = 'debug', label = name) { const logger = winston.loggers.get('main') logger.info(config.name + ' waking up and ready for service') -const mqtt = require('./mqtt')(config.mqtt, god) -god.mqtt = mqtt -// initialization race condition, hope for the best... (later code parts could already access mpd1/2 before the async func finishes) +// TODO add loggers +const wodoinco = require('./wodoinco')('/dev/ttyWoDoInCo') +const extender = require('./extender')('/dev/ttyExtender') + +if (config.mqtt) { + const mqtt = require('./mqtt')(config.mqtt, god) + god.mqtt = mqtt +} + +// initialization race condition, hope for the best... (later code parts could already access mpd before the async func finishes) var mpd -(async () => { mpd = await require('./mpd')(god, 'localhost', 'mpd') })() +(async () => { mpd = await require('./mpd')(god, 'localhost', 'mpd', 'medusa-mpd') })() const web = require('./web')(god, 'web') //const gpio = require('./gpio')(god, 'gpio') @@ -220,10 +227,8 @@ const web = require('./web')(god, 'web') const network = require('./network')(god, 'net') const scenario = require('./scenario')(god, 'scenario') //const screenkeys = require('./screenkeys')(god, 'keys') -const things = require('./things')(god, 'things') -// TODO add loggers, update constructor signature -const wodoinco = require('./wodoinco')('/dev/ttyWoDoInCo') -const extender = require('./extender')('/dev/ttyExtender') +god.zwave = require('./zwave.js')(god) +god.thingController = require('./things')(god, 'things') @@ -496,13 +501,15 @@ async function wodoinco2(item, value) { console.log("Switching Light off") } else { console.log("Unknown command for Light: " + value) + return } } let result = await wodoinco.send(txt); console.log("Wodoinco2: result='" + result + "'") } - io.of('/browser').on('connection', async (socket) => { + +io.of('/browser').on('connection', async (socket) => { god.ioSocketList[socket.id] = { socket: socket, subscriptions: {} @@ -604,10 +611,19 @@ god.ioOnConnected.push(socket => socket.on('things', function(data) { logger.debug('Pushing full thing-config to client on request') socket.emit('things', Object.values(god.things).map(thing => thing.fullJson)) } + if (data == 'retrieveThingGroups') { + logger.debug('Pushing all groups to client on request') + socket.emit('thingGroups', god.thingController.getGroupDefinitions()) + } + if (data == 'retrieveScenarios') { + logger.debug('Pushing all scenarios to client on request') + socket.emit('scenarios', god.thingController.getScenario()) + socket.emit('thingScenario', god.thingController.getCurrentScenario()) + } if (data.id && data.action) { - things.onAction(data.id, data.action) + god.thingController.onAction(data.id, data.action) } -})) + })) god.onThingChanged.push(thing => god.whiteboard.getCallbacks('things').forEach(cb => cb(thing.json))) const ignore = () => {} @@ -646,8 +662,8 @@ web.addListener("cave", "Pum", async (req, res) => { openhab('pum', 'TOG wodoinco.addListener("A Tast A", async (txt) => { console.log("WoDoInCo: Light toggled: " + txt) }) wodoinco.addListener("A Tast B", async (txt) => { extender2('Speaker', 'on'); console.log((await mpd.fadePlay(2)) + " (" + (await mpMpdVol90()) + ")" ) }) wodoinco.addListener("A Tast C", async (txt) => { extender2('Speaker', 'timed-off'); console.log(await mpd.fadePause(45)) }) -wodoinco.addListener("A Tast Do", async (txt) => { console.log(await changeVolume(+2)) }) -wodoinco.addListener("A Tast Du", async (txt) => { console.log(await changeVolume(-2)) }) +wodoinco.addListener("A Tast Do", async (txt) => { console.log(await mpd.changeVolume(+2)) }) +wodoinco.addListener("A Tast Du", async (txt) => { console.log(await mpd.changeVolume(-2)) }) wodoinco.addListener("A PC Light to 0", ignore ) wodoinco.addListener("A PC Light to 1", ignore ) diff --git a/mpd.js b/mpd.js index 70406a4..06020b6 100755 --- a/mpd.js +++ b/mpd.js @@ -12,7 +12,7 @@ var Q = require('q') const util = require('util') const moment = require('moment') - module.exports = async function(god, mpdHost = 'localhost', id = 'mpd') { +module.exports = async function(god, mpdHost = 'localhost', id = 'mpd', _mqttTopic = undefined) { var self = { mappingFilename: 'mpd-youtube-cache.json', @@ -25,7 +25,7 @@ const moment = require('moment') mpdstatus: {}, faderTimerId: undefined, logger: {}, - mqttTopic: 'grag-' + id, + mqttTopic: _mqttTopic ?? id, watchdog: { counter: 0, maxReconnectTries: 1, // warning: this is a sync-recursive call diff --git a/public/grag3.html b/public/grag3.html index 328852a..1792349 100755 --- a/public/grag3.html +++ b/public/grag3.html @@ -201,13 +201,14 @@ console.log('AUTO', thing.id, thing.def.render.autohide, currentScenario?.hide?.indexOf(thing.id), thing.status) if (thing.def.render.autohide) return true if (currentScenario?.hide?.indexOf(thing.id) > -1) return true + if (thing.status == 'dead' && thing.def.render.hiddenIfDead) return true return false } // returns true if a thing is hideable, alive (except hiddenIfDead), and the value is as the scenario expects it function calcIfAutoHidden(thing) { + if (thing.status == 'dead' && thing.def.render.hiddenIfDead) return true if (!calcIfHideable(thing)) return false - if (thing.status == 'dead') return thing.def.render.hiddenIfDead return calcScenarioExpectation(thing).asExpected } diff --git a/scenario.js b/scenario.js index 4d62dad..a0b0719 100755 --- a/scenario.js +++ b/scenario.js @@ -55,6 +55,7 @@ const winston = require('winston') init: async function() { this.logger = winston.loggers.get(loggerName) god.mqtt.addTrigger('cmnd/' + this.mqttTopic, 'cmnd-scenario', this.onMqttCmnd.bind(this)) + if (!god.config.scenarios) god.config.scenarios = { "": {} } Object.keys(god.config.scenarios).forEach(key => this.initTriggers(key, god.config.scenarios[key])) }, diff --git a/things.js b/things.js index a2e0165..ef2baec 100755 --- a/things.js +++ b/things.js @@ -72,10 +72,12 @@ class Thing { return this.def.id } + /** This function is called when a thing-specific action should be triggered, e.g. "switch light on". For most things this sends the appropriate MQTT commands */ onAction(data) { this.logger.warn('Abstract base class for ' + this.id + ': action not supported') } + /** internally used to change this.status, propagates the new value to listeners (can be skipped if done manually anyway) */ setstatus(newStatus, propagateChange = true) { if (this.status != newStatus) { if (this.status == ThingStatus.dead) this.logger.info(this.def.id + ' is alive again') @@ -120,6 +122,7 @@ class Thing { } } + /** Called from checkAlive() when a thing is considered stale/dead. Should try to provoke the thing to answer something. */ poke(now) { this.logger.warn('Abstract base class for ' + this.id + ': poking not supported') this.lastpoked = now @@ -131,8 +134,8 @@ class MusicPlayer extends Thing { constructor(id, def) { super(id, def) this.lastState = {} - this.onMqttStateUpdate = this.onMqttStateUpdate.bind(this) - god.mqtt.addTrigger('tele/' + def.device + '/STATE', def.id, this.onMqttStateUpdate) + this.onMpdMqttStateUpdate = this.onMpdMqttStateUpdate.bind(this) + god.mqtt.addTrigger('tele/' + def.device + '/STATE', def.id, this.onMpdMqttStateUpdate) } @@ -148,7 +151,7 @@ class MusicPlayer extends Thing { } // Callback for MQTT messages for the MPD subsystem - async onMqttStateUpdate(trigger, topic, message, packet) { + async onMpdMqttStateUpdate(trigger, topic, message, packet) { let newState = message.toString() try { let json = JSON.parse(newState) @@ -412,6 +415,7 @@ class Onkyo extends Thing { } +/** Represents a simple, stateless button on the UI which triggers a specific MQTT message */ class Button extends Thing { constructor(id, def) { super(id, def) @@ -435,8 +439,96 @@ class Button extends Thing { god.mqtt.publish(topic, message) } + /** Buttons can't be poked */ poke(now) { - this.lastpoked = now + } +} + +class ZWave extends Thing { + constructor(id, def) { + super(id, def) + this.status = ThingStatus.ignored + god.zwave.addChangeListener(this.onZWaveUpdate.bind(this)) + } + + get json() { + return { + id: this.def.id, + lastUpdated: this.lastUpdated, + status: this.status.name, + value: god.zwave.getNodeValue(this.def.nodeId, '37/' + (this.def.nodeSubId ?? 0) + '/currentValue/value') ? 'ON' : 'OFF' + } + } + + /** called from zwave.js when an MQTT update is received */ + onZWaveUpdate(nodeId, nodeData, relativeTopic, value) { + if (nodeId != this.def.nodeId) return + let propagateChange = false + if (relativeTopic == '37/' + (this.def.nodeSubId ?? 0) + '/currentValue') propagateChange = true + if (relativeTopic == 'status/status') { + let newStatus = ThingStatus.dead + if (nodeData?.status?.status == 'Alive') newStatus = ThingStatus.alive + if (this.status != newStatus) { + this.setstatus(newStatus, false) + propagateChange = true + } + } + this.logger.warn('ZWave update on node %s: %s = %s (propagate=%s)', nodeId, relativeTopic, value, propagateChange) + this.lastUpdated = new Date() // update timestamp even if the value is unchanged + if (propagateChange) { + god.onThingChanged.forEach(cb => cb(this)) + } + } + + onAction(action) { + let topic = 'zwave/' + this.def.nodeId + '/37/' + (this.def.nodeSubId ?? 0) + '/targetValue/set' + let message = action == 'ON' ? "true" : "false" + this.logger.info('Action for %s (%o): send "%s" "%s"', this.def.id, action, topic, message) + god.mqtt.publish(topic, message) + } + +// TODO alive check not working: neither stale nor lastUpdate updating nor check for 'Dead' + checkAlive(now) { + switch (this.status) { + case ThingStatus.ignored: + // no updating, no poking + break; + case ThingStatus.alive: + if (now - this.lastUpdated > Thing.consideredStaleMs) { + this.setstatus(ThingStatus.stale) + this.logger.info('Status for ' + this.def.id + ' has gone stale, poking it') + this.poke(now) + } else { + let nodeData = god.zwave.getNode(this.def.nodeId) + if (nodeData?.status?.status != 'Alive') this.setstatus(ThingStatus.dead) + } + break; + case ThingStatus.uninitialized: + case ThingStatus.stale: + if (now - this.lastUpdated > Thing.consideredDeadMs) { + this.setstatus(ThingStatus.dead) + this.logger.info(this.def.id + ' appears to be dead :(') + this.poke(now) + } + if (now - this.lastpoked > Thing.pokeIntervalMs) { + this.poke(now) + } + break; + case ThingStatus.dead: + if (now - this.lastpoked > Thing.pokeIntervalMs) { + this.poke(now) + } + break; + default: + this.logger.error('ThingStatus for ' + this.id + ' is invalid: ' + this.status) + this.setstatus(ThingStatus.ignored) + break; + } + } + + // does nothing - don't know how to poke ZWave things, or zwave-js + poke(now) { +// this.lastpoked = now } } @@ -563,6 +655,8 @@ module.exports = function(god2, loggerName = 'things') { god.things[def.id] = new Button(def.id, def) } else if (def.api == 'onkyo') { god.things[def.id] = new Onkyo(def.id, def) + } else if (def.api == 'zwave') { + god.things[def.id] = new ZWave(def.id, def) } else { this.logger.error('Thing %s has undefined api "%s"', def.id, def.api) } diff --git a/zwave.js b/zwave.js new file mode 100755 index 0000000..61382e4 --- /dev/null +++ b/zwave.js @@ -0,0 +1,82 @@ +/* Connects to ZWave2MQTT on topic zwave/# + */ + +const winston = require('winston') + +module.exports = function(god, loggerName = 'zwave') { + var self = { + + mqttTopic: 'zwave', + nodes: {}, + onChangeListeners: [], + + init: function() { + this.logger = winston.loggers.get(loggerName) + god.mqtt.addTrigger(this.mqttTopic + '/#', 'zwave', this.onMqttMessage.bind(this)) + }, + + addChangeListener: function(callback) { + this.onChangeListeners.push(callback) + }, + + async onMqttMessage(trigger, topic, message, packet) { + let value = message.toString() + this.logger.debug('Received mqtt %s: %s', topic, value) + let pathSegments = topic.split('/') + if (pathSegments.length < 2 || pathSegments[0] != 'zwave') { + this.logger.warn('Topic can\'t be parsed: %s', topic) + return + } + let nodeId + if (pathSegments[1].startsWith('nodeID_')) { + relativeTopic = topic.substr(pathSegments[0].length + pathSegments[1].length + 2) + pathSegments.shift() // 'zwave' + nodeId = pathSegments[0] + } else { + relativeTopic = topic.substr(pathSegments[0].length + pathSegments[1].length + pathSegments[2].length + 3) + pathSegments.shift() // 'zwave' + nodeId = pathSegments[0] + '/' + pathSegments[1] + pathSegments.shift() // location + } + pathSegments[0] = nodeId // use short form as array key + let targetObj = this.nodes + try { + value = JSON.parse(value) + } catch(e) {} + while(pathSegments.length) { + let segment = pathSegments.shift() + if (pathSegments.length) { + if (!targetObj[segment]) targetObj[segment] = {} + targetObj = targetObj[segment] + } else { + targetObj[segment] = value + this.logger.debug('on node %s, setting %s to %o', nodeId, relativeTopic, value) + this.onChangeListeners.forEach(cb => cb(nodeId, this.nodes[nodeId], relativeTopic, value)) + } + } + }, + + getNode(nodeId) { + return this.nodes[nodeId] ?? {} + }, + + /** Retrieves a cached value for the given nodeId. If this node has not been seen yet on the requested path, returns undefined */ + getNodeValue(nodeId, path) { + let value = this.nodes[nodeId] + if (!value) return undefined + let pathSegments = path.split('/') + while(pathSegments.length) { + let segment = pathSegments.shift() + if (value[segment]) + value = value[segment] + else + return undefined + } + this.logger.debug('getNodeValue(%s, %s) = %o', nodeId, path, value) + return value + } + +} + self.init() + return self +} diff --git a/zwave.mjs b/zwave.mjs deleted file mode 100755 index 9c75196..0000000 --- a/zwave.mjs +++ /dev/null @@ -1,48 +0,0 @@ -import { Driver } from "zwave-js"; - -// Or when the application gets a SIGINT or SIGTERM signal -// Shutting down after SIGINT is optional, but the handler must exist -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, async () => { - await driver.destroy(); - process.exit(0); - }); -} - -// Tell the driver which serial port to use -const driver = new Driver("/dev/ttyZWave"); -// You must add a handler for the error event before starting the driver -driver.on("error", (e) => { - // Do something with it - console.error(e); -}); -// Listen for the driver ready event before doing anything with the driver -driver.once("driver ready", () => { -console.log("driver ready") - /* - Now the controller interview is complete. This means we know which nodes - are included in the network, but they might not be ready yet. - The node interview will continue in the background. - */ - - driver.controller.nodes.forEach((node) => { -console.log(node) - // e.g. add event handlers to all known nodes - }); -console.log(driver.controller.nodes.length) - // When a node is marked as ready, it is safe to control it - const node = driver.controller.nodes.get(0); - node.once("ready", async () => { - // e.g. perform a BasicCC::Set with target value 50 -console.log("node ready") -// await node.commandClasses.Basic.set(50); - }); -}); -// Start the driver. To await this method, put this line into an async method -await driver.start(); - -setTimeout(async () => { console.log("Shutting down again..."); await driver.destroy(); }, 20000) - -// When you want to exit: - -