UNPKG

signalk-notification-player

Version:

Customizable audio and text to speech playback of Signal K notifications w/ optional Slack integration

1,190 lines (1,114 loc) 46.3 kB
/* * Copyright 2024 David Sanner * * 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. */ const _ = require('lodash') const fs = require('fs') const fspath = require('path') const child_process = require('child_process') const say = require('say') const SlackNotify = require('slack-notify') module.exports = function (app) { let plugin = {} plugin.id = 'signalk-notification-player' plugin.name = 'Notification Player' plugin.description = 'Plugin that plays notification sounds/speech' const maxDisable = 3600 // max disable all time in seconds const playBackTimeOut = 60000 // 60s failsafe override playBack let unsubscribes = [] let queueActive = false // keeps track of running the preCommand let playBackActive = false let hasFestival = false let pluginProps let playPID let queueIndex = 0 let muteUntil = 0 let vesselName let listFile, logFile //let lastAlert = '' const alertQueue = new Map() const alertLog = {} // used to keep track of recent alerts to control bouncing in/out of zone const notificationList = {} // notification state, disabled setting saved to disk let notificationLog = [] // long term timestamped log for every notification event const notificationFiles = ['builtin_alarm.mp3', 'builtin_notice.mp3', 'builtin_sonar.mp3', 'builtin_tritone.mp3'] const notificationSounds = { emergency: notificationFiles[0], alarm: notificationFiles[1], warn: notificationFiles[2], alert: notificationFiles[3] } const enableNotificationTypes = { emergency: 'continuous', alarm: 'continuous', warn: 'single notice', alert: 'single notice' } const notificationPrePost = { emergency: true, alarm: true, warn: true, alert: false } const soundEvent = { path: '', state: '', audioFile: '', message: '', mode: '', played: 0, numNotifications: 0, playAfter: 0, disabled: false } plugin.start = function (props) { pluginProps = props if (!pluginProps.repeatGap) pluginProps.repeatGap = 0 if (process.platform === 'linux') { // quick check if festival installed for linux process.env.PATH.replace(/["]+/g, '') .split(fspath.delimiter) .filter(Boolean) .forEach((element) => { if (fs.existsSync(element + '/festival')) hasFestival = true }) if (!hasFestival) app.error('Error: please install festival package') } if (pluginProps.mappings) pluginProps.mappings.forEach((m) => { if (typeof m.alarmAudioFileCustom !== 'undefined') m.alarmAudioFile = m.alarmAudioFileCustom }) if (!(vesselName = app.getSelfPath('name'))) vesselName = 'Unnamed' if (!pluginProps.playbackControlPrefix) pluginProps.playbackControlPrefix = 'digital.notificationPlayer' listFile = fspath.join(app.getDataDirPath(), 'notificationList.json') readListFile(listFile) logFile = fspath.join(app.getDataDirPath(), 'notificationLog.json') readLogFile(logFile) //const openapi = require('./openApi.json'); plugin.getOpenApi = () => openapi delay(4000).then(() => { subscribeToNotifications() // also startup of SK so wait for things to settle and then check we did't miss any notifications findObjectsEndingWith(app.getSelfPath('notifications'), 'value').forEach(function (update) { // load notificationList update.path = 'notifications.' + update.path processNotifications({ updates: [{ values: [update] }] }) }) }) subscribeToHandlers() } plugin.stop = function () { unsubscribes.forEach(function (func) { func() }) unsubscribes = [] } function subscriptionError(err) { app.error('error: ' + err) } //// function processNotifications(fullNotification) { fullNotification.updates.forEach(function (update) { update.values.forEach(function (notification) { // loop for each notification update const nPath = notification.path const value = notification.value let bounceBlock = false //if(value.state != 'normal' ) app.debug('notification path:', nPath, 'value:', value) // value.nPath & value.value //app.debug('notification path:', nPath, 'value:', value) // value.nPath & value.value if (value != null && typeof value.state !== 'undefined') { if (typeof notificationList[nPath] !== 'undefined') notificationList[nPath] = { state: value.state, disabled: notificationList[nPath].disabled } else notificationList[nPath] = { state: value.state, disabled: false } let continuous = false let notice = false let noPlay = false let msgServiceAlert = false let playAfter = 0 let audioFile = notificationSounds[value.state] let repeatGap = pluginProps.repeatGap //let ppm = pluginProps.mappings // // check for custom notice & configure if found if ( pluginProps.mappings && nPath && value.state && (notification = pluginProps.mappings.find((ppm) => ppm.path === nPath && ppm.state === value.state)) ) { //app.debug('Found custom notification', notification ) if (notification.alarmAudioFile) audioFile = notification.alarmAudioFile if (notification.alarmType == 'continuous') continuous = true else if (notification.alarmType == 'single notice') notice = true if (notification.noPlay == true) noPlay = true if (notification.repeatGap !== undefined) repeatGap = notification.repeatGap if (notification.msgServiceAlert) msgServiceAlert = true if (notification.playAfter) playAfter = now() + notification.playAfter * 1000 } else { if (enableNotificationTypes[value.state] == 'continuous') continuous = true else if (enableNotificationTypes[value.state] == 'single notice') notice = true } let eventTimeStamp if (update.timestamp) eventTimeStamp = new Date(update.timestamp).getTime() else eventTimeStamp = now() //let eventTimeStamp = update.timestamp ? new Date(update.timestamp).getTime() : now(); if (!alertLog[nPath + '.' + value.state] || // bounce prevention: check alertLog for recent notification of this type alertLog[nPath + '.' + value.state].timestamp + repeatGap * 1000 < eventTimeStamp || // true enough time has passed, eg not bouncing value.state == 'emergency' || // never block notifications of type: emergency or alarm value.state == 'alarm'|| notificationLog.findLast((item) => item.path === nPath.substring(nPath.indexOf('.') + 1)).state != value.state && value.state == 'normal' // always log normal if prev log state was not normal ) bounceBlock = false else bounceBlock = true let duplicate = false let args = Object.create(soundEvent) if (notice) args.mode = 'notice' else if (continuous) args.mode = 'continuous' else args.mode = 'none' args.audioFile = audioFile args.path = nPath args.state = value.state args.played = 0 args.playAfter = playAfter args.disabled = notificationList[nPath].disabled args.numNotifications = 0 if (audioFile && !noPlay) args.numNotifications++ else args.audioFile = '' if (value.message) { args.numNotifications++ args.message = value.message } //lastAlert = args.path + '.' + args.state alertLog[args.path + '.' + args.state] = { message: args.message, timestamp: eventTimeStamp } let inQ = alertQueue.has(nPath) // Process/play notification if new path entry or if changing existing path's state (eg. alarm to alert to normal) // or if messages changes && not bouncing/recent (except alarm & emergency) if ( (inQ || notice || continuous) && (!inQ || !alertQueue.get(nPath).state || alertQueue.get(nPath).state != value.state || alertQueue.get(nPath).message != value.message) && !bounceBlock ) { // Finally add to alertQueue for sound processing if (args.state != 'normal' && typeof value.method !== 'undefined' && value.method.indexOf('sound') != -1) { // only Q if sound Method if (args.disabled != true) { alertQueue.set(nPath, args) // active notification state, path not disabled, Q it! app.debug( 'ADD2Q:' + args.path.substring(args.path.indexOf('.') + 1), args.mode, args.state, 'qSize:' + alertQueue.size ) processQueue() } } else if (inQ) { // notification path was in Q but no longer has sound Method or state normal alertQueue.delete(nPath) app.debug('RMFQ:', args.path.substring(args.path.indexOf('.') + 1), 'qSize:', alertQueue.size) } if (msgServiceAlert && pluginProps.slackWebhookURL) { try { SlackNotify(pluginProps.slackWebhookURL).send({ channel: pluginProps.slackChannel, text: vesselName + ': ' + args.message, fields: { 'SignalK Notification': args.path + ' / ' + args.state, Message: args.message + ' @ ' + new Date(eventTimeStamp).toISOString(), Value: app.getSelfPath(args.path.substring(args.path.indexOf('.') + 1) + '.value') } }); app.debug('Slack notification sent:', args.path); } catch (error) { app.error('Slack notification failed:', error.message); } } } else if (inQ) { if (typeof value.method !== 'undefined' && value.method.indexOf('sound') == -1) { app.debug('RMFQ: no sound method, removing') alertQueue.delete(nPath) // no sound method } else if (!notice && !continuous) { // resolved: state's notificationType has no continuous or single notice method, typical back to normal state app.debug('RMFQ: no method, removing') if (alertQueue.get(nPath).played != true && alertQueue.get(nPath).playAfter < now()) { // unless in playAfter state // try and play at least once but if cleared then only once alertQueue.get(nPath).mode = 'notice' } else alertQueue.delete(nPath) // no continuous or single notice method for this state so delete } } else { duplicate = true } if (!bounceBlock) { // log if(value.state == 'normal') { // add normal states logNotification({ path: nPath, state: value.state }) } else logNotification({ path: args.path, state: args.state, mode: args.mode, disabled: args.disabled }) } else if (!duplicate){ app.debug("Notification blocked:"+nPath, value.state, 'bounceBlock:'+bounceBlock) if (alertQueue.has(nPath)) { // odd case where going from active to blocked state, remove active alertQueue.delete(nPath) app.debug('RMFQ w/ block:', args.path.substring(args.path.indexOf('.') + 1), 'qSize:', alertQueue.size) } } } }) // end loop for each notification update }) if (alertQueue.size === 0 && queueActive) { stopProcessingQueue() } } function stopProcessingQueue() { //app.debug('stop playing') if (typeof playPID === 'number') process.kill(playPID) if (queueActive && pluginProps.postCommand && pluginProps.postCommand.length > 0) { queueActive = false const { exec } = require('node:child_process') app.debug('post-command: %s', pluginProps.postCommand) exec(pluginProps.postCommand) } else { queueActive = false } } function playEvent(soundEvent) { soundEvent.played++ try { //app.debug('SOUND EVENT:',soundEvent) if ( notificationPrePost[soundEvent.state] != false && queueActive != true && pluginProps.preCommand && pluginProps.preCommand.length > 0 ) { queueActive = true const { exec } = require('node:child_process') app.debug('pre-command: %s', pluginProps.preCommand) try { exec(pluginProps.preCommand) } catch (error) { app.error('ERROR:' + error) playBackActive = false processQueue() } } else queueActive = true if ((soundEvent.message && soundEvent.played == 2) || (!soundEvent.audioFile && soundEvent.played == 1)) { if (process.platform === 'linux' && !hasFestival) { app.debug('skipping saying:' + soundEvent.message, 'mode:' + soundEvent.mode, 'played:' + soundEvent.played) playBackActive = false processQueue() } else { app.debug('saying:' + soundEvent.message, 'mode:' + soundEvent.mode, 'played:' + soundEvent.played) try { say.speak(soundEvent.message, null, null, (err) => { playBackActive = false processQueue() }) } catch (error) { app.error('ERROR:' + error) playBackActive = false processQueue() } } } else if (soundEvent.audioFile) { const command = pluginProps.alarmAudioPlayer let soundFile = soundEvent.audioFile if (soundFile && soundFile.charAt(0) != '/') { soundFile = fspath.join(__dirname, 'sounds', soundFile) } if (fs.existsSync(soundFile)) { let args = [soundFile] if (pluginProps.alarmAudioPlayerArguments && pluginProps.alarmAudioPlayerArguments.length > 0) { args = [...pluginProps.alarmAudioPlayerArguments.split(' '), ...args] } app.debug('playing:' + soundEvent.audioFile, 'mode:' + soundEvent.mode, 'played:' + soundEvent.played) let play = child_process.spawn(command, args) playPID = play.pid play.on('error', (err) => { playPID = undefined app.error('Failed to play sound ' + err) playBackActive = false processQueue() }) play.on('close', (code) => { playBackActive = false processQueue() }) } else { app.debug('not playing, sound file missing:' + soundFile) playBackActive = false processQueue() } } } catch (error) { // catch all to make sure processing continue, no lockout app.error('PLAYBACK ERROR:' + error) playBackActive = false processQueue() } } function processQueue() { if (!playBackActive || playBackActive + playBackTimeOut < now()) { playPID = undefined if (muteUntil) { app.debug('Muted in processQueue to', muteUntil) // should we ever be here? } else if (alertQueue.size > 0) { if (queueIndex >= alertQueue.size) { queueIndex = 0 } const audioEvent = Array.from(alertQueue)[queueIndex][1] //app.debug('AE', audioEvent) // Q item not playable yet, move to next Q item, if no playable sleep if ((audioEvent.playAfter != 0 && audioEvent.playAfter > now()) || audioEvent.disabled) { queueIndex++ let playableInQ = 0 alertQueue.forEach((value, key) => { if (!value.playAfter && !value.disabled) playableInQ++ }) if (playableInQ) { delay(100).then(() => { processQueue() }) } else { if (queueActive) stopProcessingQueue() // rare case when Q was active but now only waiting items //app.debug('Sleeping', (audioEvent.playAfter - now()) / 1000) delay(5000).then(() => { // Q only contains non-playable items processQueue() }) } // Q item playable - play! } else if (audioEvent.played < audioEvent.numNotifications) { playBackActive = now() // timer / semaphore to prevent overlap of playback playEvent(audioEvent) // Process Q item(s) } else { if (audioEvent.mode != 'continuous') { // single play so delete alertQueue.delete(audioEvent.path) } else { // continuous type, so reset counter audioEvent.played = 0 } if (alertQueue.size > 0) { // increment to next in queue if (++queueIndex >= alertQueue.size) queueIndex = 0 delay(250).then(() => { processQueue() }) } else { if (queueActive) stopProcessingQueue() app.debug('Queue Empty, waiting...') } } } else { if (queueActive) stopProcessingQueue() app.debug('Queue Empty, waiting ...') } } } function logNotification(args) { const path = args.path.substring(args.path.indexOf('.') + 1) const arg2Log = { path: path, state: args.state } const lastEvent = notificationLog.findLast((item) => item.path === path) if (!lastEvent || lastEvent.state != args.state) { try { //process.nextTick(() => { // weird hack to get updated value, w/o async call gets prev value if (typeof app.getSelfPath(path) !== 'undefined') { if (path == 'navigation.anchor') // anchor watch path hack arg2Log.value = app.getSelfPath(path).currentRadius.value else { if(app.getSelfPath(path) && typeof app.getSelfPath(path).value !== 'undefined') arg2Log.value = app.getSelfPath(path).value else arg2Log.value = null } if(typeof arg2Log.value === 'number' && !Number.isInteger(arg2Log.value)) arg2Log.value = arg2Log.value.toPrecision(7) * 1 } else arg2Log.value = null arg2Log.datetime = now() if(args.mode !== undefined) arg2Log.mode = args.mode if(args.disabled !== undefined) arg2Log.disabled = args.disabled if (!fs.existsSync(logFile)) { fs.writeFileSync(logFile, JSON.stringify(arg2Log)) } else { fs.appendFileSync(logFile, ',\n' + JSON.stringify(arg2Log)) } //}) } catch (e) { app.error('Could not write:', logFile, '-', e) } notificationLog.push(arg2Log) } maintainLog(false) } function maintainLog(forceCheck) { // Manage growing notificationLog and logFile size / always trouble if( notificationLog.length > 25000 || forceCheck ) { // check array size - edge case, or force check logFile at startup notificationLog = notificationLog.slice(-20000) if( fs.statSync(logFile).size > 5242880) { // chop down log file to something reasonable @ max 5 megs? //if( fs.statSync(logFile).size > 10000) { // TESTING const maxEntries = 150 // truncate to max entries per path (approx 1M w/ 50 paths) try { const jsonArray = JSON.parse('[' + fs.readFileSync(logFile, 'utf-8') + ']') // wrap in [] const lastEntries = jsonArray.filter((item, index, arr) => { const indices = arr.map((el, i) => (el.path === item.path ? i : -1)).filter((i) => i !== -1) return indices.slice(-maxEntries).includes(index) }) const newJsonString = lastEntries.map((obj) => JSON.stringify(obj)).join(',\n') fs.writeFileSync(logFile, newJsonString, 'utf-8') // Overwrite the file with the truncated content app.debug(`Log file truncated to the last ${maxEntries} objects.`) } catch (error) { app.error('Error:', error) } } } } function now() { return Math.floor(Date.now()) } function delay(time) { return new Promise((resolve) => setTimeout(resolve, time)) } function readListFile(listFile) { if (fs.existsSync(listFile)) { let listString let list try { listString = fs.readFileSync(listFile, 'utf8') } catch (e) { app.error('Could not read ' + listFile + ' - ' + e) return } try { list = JSON.parse(listString) } catch (e) { app.error('Could not parse ' + e) return } for (const [path, val] of Object.entries(list)) { //if(val.disabled && typeof notificationList[path] !== 'undefined') { if (val.disabled) { if (val.disabled) { alertQueue.delete(path) // remove event from Q / silence at startup if (typeof notificationList[path] !== 'undefined') notificationList[path].disabled = true else notificationList[path] = { state: '', disabled: true } } } } } } function readLogFile(logFile) { if (fs.existsSync(logFile)) { let logString let logArray = [] try { maintainLog(true) // trim log file if needed logString = fs.readFileSync(logFile, 'utf8') } catch (e) { app.error('Could not read ' + logFile + ' - ' + e) return } if(logString) { try { logArray = JSON.parse('[' + logString + ']') // wrap with [] } catch (e) { app.error('Could not parse logfile ' + logFile + e) try { fs.renameSync(logFile, logFile + "-bck") } catch (e) { app.error('Could not move logfile ' + logFile + e) } return } } for (const logEntry of Object.values(logArray)) { notificationLog.push(logEntry) } const maxEntries = 50 // Trim notificationLog array down to last 50 entries for each path const lastEntries = notificationLog.filter((item, index, arr) => { const indices = arr.map((el, i) => (el.path === item.path ? i : -1)).filter((i) => i !== -1) return indices.slice(-maxEntries).includes(index) }) notificationLog = lastEntries } } function findObjectsEndingWith(obj, ending) { const results = [] function traverse(current, path = '') { for (const key in current) { if (current.hasOwnProperty(key)) { let newPath = path ? `${path}.${key}` : key if (key.endsWith(ending)) { newPath = newPath.substring(0, newPath.lastIndexOf('.')) // strip final ending results.push({ path: newPath, value: current[key] }) } if (typeof current[key] === 'object' && current[key] !== null) traverse(current[key], newPath) } } } traverse(obj) return results } plugin.schema = function () { let defaultAudioPlayer = 'mpg321' if (process.platform === 'darwin') defaultAudioPlayer = 'afplay' let notificationTypes = ['continuous', 'single notice', '-PLAYBACK DISABLED-'] let schema = { type: 'object', description: 'Default Playback Method for Each (Emergency/Alarm/Warn/Alert) Notification Type:', required: ['enableEmergencies', 'enableAlarms', 'enableWarnings', 'enableAlerts'], properties: { t1: { type: 'object', title: 'Emergencies Notification Settings' }, enableEmergencies: { type: 'string', enum: notificationTypes, title: 'Emergency - Playback Method', default: 'continuous' }, emergencyAudioFile: { type: 'string', enum: notificationFiles, title: 'Emergency - Notification Sound', default: notificationSounds.emergency }, emergencyAudioFileCustom: { type: 'string', title: 'Emergency - Custom Notification Sound', description: 'Full Path to Sound File (overrides above setting)' }, prePostEmergency: { type: 'boolean', title: 'Run Custom Pre/Post Commands for Emergency Notifications', default: true }, t2: { type: 'object', title: 'Alarm Notification Settings' }, enableAlarms: { type: 'string', enum: notificationTypes, title: 'Alarm - Playback Method', default: 'continuous' }, alarmAudioFile: { type: 'string', enum: notificationFiles, title: 'Alarm - Notification Sound', default: notificationSounds.alarm }, alarmAudioFileCustom: { type: 'string', title: 'Alarm - Custom Notification Sound', description: 'Full Path to Sound File (overrides above setting)' }, prePostAlarm: { type: 'boolean', title: 'Run Custom Pre/Post Commands for Alarm Notifications', default: true }, t3: { type: 'object', title: 'Warning Notification Settings' }, enableWarnings: { type: 'string', enum: notificationTypes, title: 'Warning - Playback Method', default: 'single notice' }, warnAudioFile: { type: 'string', enum: notificationFiles, title: 'Warn - Notification Sound', default: notificationSounds.warn }, warnAudioFileCustom: { type: 'string', title: 'Warn - Custom Notification Sound', description: 'Full Path to Sound File (overrides above setting)' }, prePostWarn: { type: 'boolean', title: 'Run Custom Pre/Post Commands for Warn Notifications', default: false }, t4: { type: 'object', title: 'Alert Notification Settings' }, enableAlerts: { type: 'string', enum: notificationTypes, title: 'Alert - Playback Method', default: 'single notice' }, alertAudioFile: { type: 'string', enum: notificationFiles, title: 'Alert - Notification Sound', default: notificationSounds.alert }, alertAudioFileCustom: { type: 'string', title: 'Alert - Custom Notification Sound', description: 'Full Path to Sound File (overrides above setting)' }, prePostAlert: { type: 'boolean', title: 'Run Custom Pre/Post Commands for Alert Notifications', default: false }, t5: { type: 'object', title: 'General Settings' }, preCommand: { title: 'Custom Command Before Playing Notification', description: 'optional command to run before playing/speaking', type: 'string' }, postCommand: { title: 'Custom Command After Playing Notification', description: 'optional command to run after playing/speaking', type: 'string' }, alarmAudioPlayer: { title: 'Audio Player', description: 'Select command line audio player', type: 'string', default: defaultAudioPlayer, enum: ['afplay', 'omxplayer', 'mpg321', 'mpg123'] }, alarmAudioPlayerArguments: { title: 'Audio Player Arguments', description: 'Arguments to add to the audio player command', type: 'string' }, repeatGap: { title: 'Minimum Gap Between Duplicate Notifications', description: 'Limit rate of notifications when bouncing in/out of a zone (seconds), except emergency & alarm', type: 'number', default: 0 }, playbackControlPrefix: { type: 'string', title: 'Signal K path prefix for playback control', default: 'digital.notificationPlayer', description: 'Silence and resolve notification via SK paths (eg. digital.notificationPlayer + .silence .resolve .disable)' }, slackWebhookURL: { type: 'string', title: 'Slack Webhook URL', description: 'Optional Slack messaging for Custom Actions (See: https://api.slack.com/messaging/webhooks)' }, slackChannel: { type: 'string', title: 'Slack channel', default: '#signalk' }, mappings: { type: 'array', title: 'Custom Actions For Specific Notifications', items: { type: 'object', required: ['path'], properties: { path: { type: 'string', title: 'Notification Path' }, state: { type: 'string', enum: ['emergency', 'alarm', 'warn', 'alert', 'normal', 'nominal'], title: 'Notification State', description: '(Notification Path can be assigned a custom action for each Notification State)', default: 'emergency' }, alarmType: { type: 'string', enum: notificationTypes, title: 'Playback Method', default: 'single notice' }, /* methodMute: { type: 'boolean', title: 'For - Single Notice - Notification Types Only - Silences Notification Method after Playing Once', description: 'Clears Notification Sound Method (eg. silent/mute) so other apps don\'t repeat', default: false }, */ alarmAudioFile: { type: 'string', enum: notificationFiles, title: 'Playback Sound', default: notificationSounds.emergency }, alarmAudioFileCustom: { type: 'string', title: 'Custom Playback Sound', description: 'Full Path to Sound File (overrides above setting)' }, noPlay: { type: 'boolean', title: 'Do Not Play Notification Sound', description: 'Only Speak/Say Notification Message', default: false }, repeatGap: { title: 'Minimum Gap Between Duplicate Notifications', description: 'Limit rate of notifications when bouncing in/out of this zone (seconds), ignored for emergency & alarm', type: 'number' }, playAfter: { title: 'Minimum Time Notification Must Remain in Zone Before Notification is Played', description: 'Limit Transient Notifications: Seconds notification/value must remain in this zone state before notification is played', type: 'number', default: 0 }, msgServiceAlert: { type: 'boolean', title: 'Send Notification via Slack', description: 'Send notification to Slack channel (if Webhook URL configured above)', default: false } } } } } } if (typeof pluginProps !== 'undefined') { enableNotificationTypes.emergency = pluginProps.enableEmergencies enableNotificationTypes.alarm = pluginProps.enableAlarms enableNotificationTypes.warn = pluginProps.enableWarnings enableNotificationTypes.alert = pluginProps.enableAlerts notificationPrePost.emergency = pluginProps.prePostEmergency notificationPrePost.alarm = pluginProps.prePostAlarm notificationPrePost.warn = pluginProps.prePostWarn notificationPrePost.alert = pluginProps.prePostAlert if (pluginProps.emergencyAudioFileCustom) notificationSounds.emergency = pluginProps.emergencyAudioFileCustom else if (pluginProps.emergencyAudioFile) notificationSounds.emergency = pluginProps.emergencyAudioFile if (pluginProps.alarmAudioFileCustom) notificationSounds.alarm = pluginProps.alarmAudioFileCustom else if (pluginProps.alarmAudioFile) notificationSounds.alarm = pluginProps.alarmAudioFile if (pluginProps.warnAudioFileCustom) notificationSounds.warn = pluginProps.warnAudioFileCustom else if (pluginProps.warnAudioFile) notificationSounds.warn = pluginProps.warnAudioFile if (pluginProps.alertAudioFileCustom) notificationSounds.alert = pluginProps.alertAudioFileCustom else if (pluginProps.alertAudioFile) notificationSounds.alert = pluginProps.alertAudioFile } return schema } function subscribeToHandlers() { app.handleMessage(plugin.id, { updates: [{ values: [{ path: pluginProps.playbackControlPrefix + '.disable', value: false }] }] }) app.registerPutHandler('vessels.self', pluginProps.playbackControlPrefix + '.disable', handleDisable) app.handleMessage(plugin.id, { updates: [{ values: [{ path: pluginProps.playbackControlPrefix + '.silence', value: false }] }] }) app.registerPutHandler('vessels.self', pluginProps.playbackControlPrefix + '.silence', handleSilence) app.handleMessage(plugin.id, { updates: [{ values: [{ path: pluginProps.playbackControlPrefix + '.resolve', value: false }] }] }) app.registerPutHandler('vessels.self', pluginProps.playbackControlPrefix + '.resolve', handleResolve) //app.handleMessage(plugin.id, { updates: [ { values: [ { path: 'digital.notificationPlayer.ignoreLast', value: false } ] } ] }) //app.registerPutHandler('vessels.self', 'digital.notificationPlayer.ignoreLast', handleIgnoreLast) } function subscribeToNotifications() { const command = { context: 'vessels.self', subscribe: [ { path: 'notifications.*', policy: 'instant' } ] } app.subscriptionmanager.subscribe(command, unsubscribes, subscriptionError, processNotifications) } //// function silenceNotifications(path) { if (path) { app.debug('Silencing PATH:', path) const nvalue = app.getSelfPath(path) const nmethod = nvalue.value.method.filter((item) => item !== 'sound') const delta = { updates: [ { values: [ { path: path, value: { state: nvalue.value.state, method: nmethod, message: nvalue.value.message } } ], $source: nvalue.$source } ] } app.handleMessage(plugin.id, delta) } else { // Perhaps traverse all "notifications" instead of alertQueue???? findObjectsEndingWith(app.getSelfPath('notifications'), 'value').forEach(function (update) { // load notificationList path = 'notifications.' + update.path const nvalue = app.getSelfPath(path) if (nvalue?.value?.state !== undefined && nvalue.value.state != 'normal') { app.debug('Silencing PATH:', path) const nmethod = nvalue.value.method.filter((item) => item !== 'sound') const delta = { updates: [ { values: [ { path: path, value: { state: nvalue.value.state, method: nmethod, message: nvalue.value.message } } ], $source: nvalue.$source } ] } app.handleMessage(plugin.id, delta) } }) } } function resolveNotifications(path) { app.debug('Resolve Notifcations', path) Object.entries(alertLog).forEach(([key, value]) => { // check log for any alert played including ones silenced (currently not in alertQueue) key = key.substring(0, key.lastIndexOf('.')) if (!path || key == path) { const nvalue = app.getSelfPath(key) if (nvalue.value.state != 'normal' && nvalue.value.state != 'nominal') { // only clear -> set-to-normal elevated notification states //app.debug('Resolve Clearing:', key) const delta = { updates: [ { values: [ { path: key, value: { state: 'normal', method: nvalue.value.method, message: nvalue.value.message } } ], $source: nvalue.$source } ] } app.handleMessage(plugin.id, delta) } } }) } //// function handleDisable(context, path, value, callback) { //app.debug('handleDisable', context, path, value) if (value == true) { //if( muteUntil == 0 ) if (muteUntil - maxDisable * 1000 < now() - 1000) { // bounce check, accept at max 1hz rate muteUntil = now() + maxDisable * 1000 // 1hr max app.debug('Disabling in handleDisable', value) app.handleMessage(plugin.id, { updates: [{ values: [{ path: 'digital.notificationPlayer.disable', value: value }] }] }) //muteUntil = now() + (300 * 1000) // 5 minutes delay(maxDisable * 1000).then(() => { if (muteUntil <= now() && muteUntil != 0) { // check if later timer set and if not already cleared app.debug('Enabling in handleDisable via timeout') muteUntil = 0 app.handleMessage(plugin.id, { updates: [{ values: [{ path: 'digital.notificationPlayer.disable', value: false }] }] }) processQueue() } }) } } else { app.debug('Enabling in handleDisable', value) muteUntil = 0 app.handleMessage(plugin.id, { updates: [{ values: [{ path: 'digital.notificationPlayer.disable', value: false }] }] }) processQueue() } return { state: 'COMPLETED', statusCode: 200 } } function handleSilence(context, path, value, callback) { silenceNotifications() return { state: 'COMPLETED', statusCode: 200 } } function handleResolve(context, path, value, callback) { resolveNotifications() return { state: 'COMPLETED', statusCode: 200 } } /* function handleIgnoreLast(context, path, value, callback) { if(!lastAlert) { return { state: 'COMPLETED', statusCode: 200 } } if( laVal = alertLog[lastAlert] ) { laVal.timestamp = now() + ( 1200 * 1000 ) // 20 minutes alertLog[lastAlert] = laVal // set lastAlert time in the future to silence it until then alertQueue.delete(lastAlert.substr(0, lastAlert.lastIndexOf('.'))) // clear active Q entry / any type } return { state: 'COMPLETED', statusCode: 200 } } */ /* function setZoneVal() { for (const path in notificationList) { const pathTrimmed = path.substring(path.indexOf('.') + 1) const z = pathTrimmed + '.meta.zones' app.debug('Zone Path:', z) app.getSelfPath(z).forEach(function (zone) { console.log('zone values', zone) }) } } */ // plugin.registerWithRouter = (router) => { router.get('/silence', (req, res) => { silenceNotifications(req._parsedUrl.query) res.send('Active Notifications Silenced') }) router.get('/resolve', (req, res) => { resolveNotifications(req._parsedUrl.query) res.send('Active Notifications Resolved') }) router.get('/disablePath', (req, res) => { // disable path specific playback res.send('Ok') const path = req._parsedUrl.query.split('?')[0] //app.debug('disablePath:', path+'='+req._parsedUrl.query.split('?')[1]) if (typeof notificationList[path] === 'undefined') return if (req._parsedUrl.query.split('?')[1] == 'true') { notificationList[path].disabled = true silenceNotifications(path) // silence any active notifications } else { notificationList[path].disabled = false if (alertQueue.get(path) !== undefined) { if (alertQueue.get(path).disabled) alertQueue.get(path).disabled = false } } const notificationListTrimmed = {} for (const key in notificationList) { if (notificationList[key].disabled == true) { notificationListTrimmed[key] = { disabled: notificationList[key].disabled } } } try { fs.writeFileSync(listFile, JSON.stringify(notificationListTrimmed, null, 2)) } catch (e) { app.error('Could not write ' + listFile + ' - ' + e) } }) router.get('/log', (req, res) => { let logSnip // parameters: none, numEvents OR path, path?numEvents if(req._parsedUrl.query === null) { const numEvents = 10 logSnip = notificationLog.slice(-numEvents).reverse(); } else if (!isNaN(req._parsedUrl.query.split('?')[0])) { const numEvents = req._parsedUrl.query.split('?')[0] logSnip = notificationLog.slice(-numEvents).reverse(); } else { const path = req._parsedUrl.query.split('?')[0] let numEvents = req._parsedUrl.query.split('?')[1] if (numEvents && !(numEvents > 0)) numEvents = 10 // default to last 10 events logSnip = JSON.stringify( notificationLog .filter((entry) => entry.path === path) .sort((a, b) => b.datetime - a.datetime) .slice(0, numEvents) ) } res.set({ 'Content-Type': 'application/json' }) res.send(logSnip) }) router.get('/disable', (req, res) => { // disable all playback var muteTime = parseInt(req._parsedUrl.query) if (isNaN(muteTime)) { muteTime = maxDisable } // default set @ top 3600 seconds if (muteTime > 28800) { muteTime = 18800 // max 8hr disable res.send('Disable playback for ' + muteTime + ' seconds, maxmium allowed.') } else if (muteTime < 0) { res.json(pluginProps.playbackControlPrefix) return // special case, just return path } else { res.send('Disable playback for ' + muteTime + ' seconds') } app.debug('Disable playback for next', muteTime, 'seconds') muteUntil = now() + muteTime * 1000 app.handleMessage(plugin.id, { updates: [{ values: [{ path: 'digital.notificationPlayer.disable', value: true }] }] }) delay(muteTime * 1000).then(() => { if (muteUntil <= now() && muteUntil != 0) { // check if later timer set and if not already cleared app.debug('Enable playback') app.handleMessage(plugin.id, { updates: [{ values: [{ path: 'digital.notificationPlayer.disable', value: false }] }] }) muteUntil = 0 processQueue() } }) }) router.get('/list', (req, res) => { let nvalue const vlist = {} const notificationListLocal = Object.fromEntries(Object.entries(notificationList).sort((a, b) => a[0].localeCompare(b[0]))) for (const path in notificationListLocal) { if (path == 'notifications.navigation.anchor') { nvalue = app.getSelfPath(path.substring(path.indexOf('.') + 1) + '.currentRadius') // anchor watch path hack } else { nvalue = app.getSelfPath(path.substring(path.indexOf('.') + 1)) // strip leading notifiction from typical path } const state = notificationListLocal[path].state const disabled = notificationListLocal[path].disabled if (nvalue) { const pathValues = { state: state, disabled: disabled, value: nvalue.value, units: nvalue.meta.units, timestamp: nvalue.timestamp } vlist[path] = pathValues } } res.send(vlist) }) router.get('/szv', (req, res) => { //setZoneVal() findObjectsEndingWith(app.getSelfPath('notifications'), 'value').forEach(function (update) { app.debug('PV', 'notifications.' + update.path, update.value.state) }) res.send('szv ok') }) /* router.get('/ignoreLast', (req, res) => { if(!lastAlert) { res.send('No alerts to mute.') ; return } var muteTime = parseInt(req._parsedUrl.query) if ( isNaN(muteTime) ) { muteTime = 600 } // default 600 seconds if (muteTime > maxDisable) { // max 1hr muteTime = maxDisable res.send('Muting '+lastAlert+ ' playback for '+muteTime+' seconds, maxmium allowed.') } else { res.send('Muting '+lastAlert+ ' playback for '+muteTime+' seconds') } if( laVal = alertLog[lastAlert] ) { laVal.timestamp = now() + ( muteTime * 1000 ) alertLog[lastAlert] = laVal // set lastAlert time in the future to silence it until then alertQueue.delete(lastAlert.substr(0, lastAlert.lastIndexOf('.'))) // clear active Q entry / any type } app.debug('Muting PB for', lastAlert, 'next', muteTime, 'seconds') //for (type in enableNotificationTypes) { app.debug(type) } //app.debug('alertLog:', alertLog) ; //app.debug('alertQueue:', alertQueue) }) */ } // end registerWithRouter() return plugin } // END //