signalk-push-notifications
Version:
signalk-node-server plugin that pushes SignalK notifications to WilhelmSK
922 lines (813 loc) • 26.9 kB
JavaScript
/*
* Copyright 2016 Scott Bender <scott@scottbender.net>
*
* 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 Bacon = require('baconjs');
const path = require('path')
const fs = require('fs')
const _ = require('lodash')
const { InvokeCommand, LambdaClient, LogType } = require("@aws-sdk/client-lambda")
const { createServer, Server, Socket } = require('net')
const split = require('split')
const request = require("request")
const icon = require('./icon.js');
module.exports = function(app) {
var unsubscribes = []
var plugin = {}
var last_states = {}
var config
var server
var idSequence = 0
var pushSockets = []
var switchStates = {}
var decodedIcon
var repeatingNotifications = {}
plugin.start = function(props) {
decodedIcon = JSON.parse(Buffer.from(icon, 'base64').toString('utf8'))
config = props
setupSubscriptions()
start_local_server()
}
function setupSubscriptions() {
unsubscribes.forEach(function(func) { func() })
unsubscribes = []
var command = {
context: "vessels.self",
subscribe: [{
path: "notifications.*",
period: 1000
}]
}
let devices = readJson(app, "devices" , plugin.id)
Object.values(devices).forEach(device => {
if ( device.registeredPaths ) {
Object.keys(device.registeredPaths).forEach(path => {
command.subscribe.push({
path: path,
period: 1000
})
})
}
})
app.debug('subscription: ' + JSON.stringify(command))
app.subscriptionmanager.subscribe(command, unsubscribes, subscription_error, got_delta)
}
function subscription_error(err)
{
app.error("error: " + err)
}
function got_delta(notification)
{
handleNotificationDelta(app, plugin.id,
notification,
last_states)
}
plugin.signalKApiRoutes = (router) => {
router.post("/wsk/push/registerDevice", (req, res) => {
let device = req.body
if ( device.deviceToken == undefined
|| device.deviceName == undefined
|| device.production === undefined)
{
app.debug("invalid request: %O", device)
res.status(400)
res.send("Invalid Request")
return
}
let devices = readJson(app, "devices" , plugin.id)
devices[device.deviceToken] = device
saveJson(app, "devices", plugin.id, devices, res)
})
router.post("/wsk/push/deviceEnabled", (req, res) => {
let device = req.body
if ( typeof device.deviceToken == 'undefined' )
{
app.debug("invalid request: %O", device)
res.status(400)
res.send("Invalid Request")
return
}
let key = device.targetArn || device.deviceToken
app.debug('checking enabled: %j', key)
let devices = readJson(app, "devices" , plugin.id)
if ( devices[key] == null )
{
res.status(404)
res.send("Not registered")
}
else
{
res.send("Device is registered")
}
})
router.post("/wsk/push/unregisterDevice", (req, res) => {
let device = req.body
if ( device.deviceToken === undefined
&& device.targetArn === undefined )
{
app.debug("invalid request:%O ", device)
res.status(400)
res.send("Invalid Request")
return
}
let key = device.targetArn || device.deviceToken
app.debug('unregister %j', key)
devices = readJson(app, "devices" , plugin.id)
if ( devices[key] == null )
{
res.status(404)
res.send("Not registered")
}
else
{
delete devices[key]
saveJson(app, "devices", plugin.id, devices, res)
}
})
router.post("/wsk/push/registerNotificationPaths", (req, res) => {
let deviceToken = req.body.deviceToken
let paths = req.body.paths
if ( deviceToken === undefined
|| paths === undefined
)
{
app.debug("invalid request: %O", req.body)
res.status(400)
res.send("Invalid Request")
return
}
app.debug('register paths for %j', deviceToken)
let devices = readJson(app, "devices" , plugin.id)
let device = devices[deviceToken]
if ( !device ) {
app.debug(`unknown device ${deviceToken}`)
res.status(404)
res.send("Invalid Request")
} else {
app.debug(`register paths for ${device.deviceName} ${JSON.stringify(paths)}`)
if ( device.registeredPaths === undefined ) {
device.registeredPaths = {}
}
Object.keys(paths).forEach(path => {
if ( device.registeredPaths[path] === undefined ) {
device.registeredPaths[path] = {}
}
device.registeredPaths[path].widgets = paths[path].widgets
device.registeredPaths[path].controls = paths[path].controls
/*
if ( device.registeredPaths[path].controls === undefined ) {
device.registeredPaths[path].controls = []
}
let currentControls = device.registeredPaths[path].controls
let inputC = paths[path].controls || []
inputC.forEach(control => {
let exists = currentControls.find(c => {
return c.token == control.token
})
if ( !exists ) {
currentControls.push(control)
}
})*/
})
saveJson(app, "devices", plugin.id, devices, res, () => {
setupSubscriptions()
})
}
})
return router
}
plugin.registerWithRouter = function(router) {
router.post("/registerDevice", (req, res) => {
let device = req.body
if ( typeof device.targetArn == 'undefined'
|| typeof device.deviceName == 'undefined'
|| typeof device.accessKey == 'undefined'
|| typeof device.secretAccessKey == 'undefined' )
{
app.debug("invalid request: %O", device)
res.status(400)
res.send("Invalid Request")
return
}
let devices = readJson(app, "devices" , plugin.id)
devices[req.body["targetArn"]] = device
saveJson(app, "devices", plugin.id, devices, res)
})
router.post("/deviceEnabled", (req, res) => {
let device = req.body
if ( typeof device.targetArn == 'undefined' )
{
app.debug("invalid request: %O", device)
res.status(400)
res.send("Invalid Request")
return
}
let devices = readJson(app, "devices" , plugin.id)
if ( devices[req.body["targetArn"]] == null )
{
res.status(404)
res.send("Not registered")
}
else
{
res.send("Device is registered")
}
})
router.post("/unregisterDevice", (req, res) => {
let device = req.body
if ( typeof device.targetArn == 'undefined' )
{
app.debug("invalid request:%O ", device)
res.status(400)
res.send("Invalid Request")
return
}
arn = req.body["targetArn"]
devices = readJson(app, "devices" , plugin.id)
if ( devices[arn] == null )
{
res.status(404)
res.send("Not registered")
}
else
{
delete devices[arn]
saveJson(app, "devices", plugin.id, devices, res)
}
})
}
plugin.stop = function() {
unsubscribes.forEach(function(func) { func() })
unsubscribes = []
if (server) {
server.close()
server = null
}
}
plugin.id = "push-notifications"
plugin.name = "Push Notifications"
plugin.description = "Plugin that pushes SignalK notifications to WilhelmSK"
plugin.schema = {
title: "Push Notifications",
properties: {
enableRemotePush: {
title: 'Enable Remote Push',
description: 'Send push notifications via the internet when available',
type: 'boolean',
default: true
},
localPushSSIDs: {
title: 'Local Push SSIDs',
description: 'Comma separated list if Wi-Fi SSIDs where local push should be used',
type: 'string'
},
localPushPort: {
title: 'Local Push Port',
description: 'Port on the server used for local push notifications',
type: 'number',
default: 3001
},
emergencyCritical: {
title: 'Make Emergeny Critical',
description: 'Send notifications with the emergency state as Crtical iOS Notifications',
type: 'boolean',
default: true
},
alarmCritical: {
title: 'Make Alarm Critical',
description: 'Send notifications with the alarm state as Critical iOS Notifications',
type: 'boolean',
default: false
},
criticalVolume: {
title: 'Critical Notification Volume',
description: 'Volume for critical notifications (0 to 100)',
type: 'number',
default: 100
},
criticalRepeat: {
title: 'Critical Notification Repeat',
description: 'Repeat critical notifications every X seconds, 0 to disable repeat',
type: 'number',
default: 0
},
criticalRepeatDurationSeconds: {
title: 'Critical Notification Repeat Duration',
description: 'Repeat critical notifications duration (seconds), 0 to repeat until cleared, max 3 minutes',
type: 'number',
default: 0
},
criticalNotifications: {
title: 'Critical Notifications',
description: 'These notifications will be sent as Critical iOS Notifications',
type: 'array',
items: {
type: 'string',
default: 'notifications.navigation.anchor'
}
}
}
}
function findAnyToken(device) {
let paths = device.registeredPaths
let token
if ( paths ) {
Object.keys(paths).forEach(path => {
if ( paths[path].controls ) {
let t = paths[path].controls.find(c => {
return c.token !== "unknown"
})
if ( t !== undefined ) {
token = t
}
}
})
}
return token
}
function findRegistrations(path) {
let res = {}
let devices = readJson(app, "devices" , plugin.id)
Object.values(devices).forEach(device => {
let controls = []
let widgets = []
let paths = device.registeredPaths
if ( paths ) {
let pathInfo = paths[path]
if ( pathInfo && pathInfo.widgets ) {
Object.values(pathInfo.widgets).forEach(widget => {
widgets.push(widget)
})
}
if ( pathInfo && pathInfo.controls ) {
Object.values(pathInfo.controls).forEach(control => {
if ( control.token !== "unknown" ) {
controls.push(control)
}
})
if ( pathInfo.controls.length > 0 && controls.length == 0 ) {
let any = findAnyToken(device)
if ( any ) {
controls.push(any)
}
}
}
if (controls.length > 0 || widgets.length > 0 ) {
res[device.deviceToken] = { device, controls, widgets }
}
}
})
return res
}
function deviceHasAnyControls(device) {
let res = false
Object.values(device.registeredPaths).forEach(info => {
if (info.controls && info.controls.length > 0 ) {
res = true
}
})
return res
}
function handleControlChange(vp) {
let registrations = findRegistrations(vp.path)
app.debug(`control changed ${vp.path} = ${vp.value}`)
Object.keys(registrations).forEach(deviceToken => {
let info = registrations[deviceToken]
app.debug(`sending controls: ${JSON.stringify(info.controls)} widgets: ${JSON.stringify(info.widgets)} to ${deviceToken}`)
if (info.widgets && info.widgets.length > 0 ) {
send_background_push(app, [info.device], vp.path, vp.value,
info.widgets)
}
if ( info.controls && info.controls.length > 0 ) {
send_control_push(app, info.device, info.controls, vp.path)
}
})
}
function send_control_push(app, device, controls, path) {
let isProd = device.targetArn !== undefined
? device.targetArn.indexOf('APNS_SANDBOX') == -1
: device.production
//app.debug('controls: ' + JSON.stringify(controls, 0, 2))
let body = {
production: isProd,
tokens: controls.filter(control => control.token !== undefined)
.map(control => control.token )
}
if ( body.tokens.length > 0 ) {
app.debug('sending controls push body: %j', body)
invokeLambda('sendControlUpdate', body)
.then(response => {
app.debug(response.logs)
app.debug(response.result)
let result = JSON.parse(response.result)
if ( result.body && result.body.failed && result.body.failed.length > 0 ) {
removeBadTokens(app, device, path, result.body.failed)
}
})
.catch(err => {
app.error(err)
})
}
}
function removeBadTokens(app, device, path, failed) {
let devices = readJson(app, "devices" , plugin.id)
failed.forEach( res => {
if ( res.device && res.response ) {
let token = res.device
let reason = res.response.reason
if ( reason === "BadDeviceToken" ) {
app.debug('removing bad device token %s %s %s', device.deviceName, path, token)
let dev = device.targetArn ? devices[device.targetArn] : devices[device.deviceToken]
let pathInfo = dev.registeredPaths[path]
if ( pathInfo && pathInfo.controls ) {
pathInfo.controls = pathInfo.controls.filter( info => {
info.token != token
})
}
}
}
})
saveJson(app, "devices", plugin.id, devices)
}
function handleNotificationDelta(app, id, notification, last_states)
{
//app.debug("notification: %O", notification)
let devices
try {
devices = readJson(app, "devices", id)
} catch ( err ) {
if (e.code && e.code === 'ENOENT') {
//return
}
//app.error(err)
}
notification.updates.forEach(function(update) {
if ( update.values === undefined )
return
update.values.forEach(function(value) {
if ( value.path != null
&& value.path.startsWith('notifications.') ) {
if ( value.value != null
&& typeof value.value.message != 'undefined'
&& value.value.message != null )
{
if ( (last_states[value.path] == null
&& (value.value.state != 'normal' && value.value.state != 'nominal') )
|| ( last_states[value.path] != null
&& last_states[value.path] != value.value.state) )
{
last_states[value.path] = value.value.state
app.debug("message: %s", value.value.message)
let push_devices = []
if ( typeof config.enableRemotePush === 'undefined'
|| config.enableRemotePush )
{
_.forIn(devices, function(device, arn) {
if ( !deviceIsLocal(device) ) {
push_devices.push(device)
} else {
app.debug("Skipping device %s because it's local", device.deviceName)
}
})
send_push(app, push_devices, value.value.message, value.path, value.value.state)
}
send_local_push(value.value.message, value.path, value.value.state)
if ( config.criticalRepeat !== undefined && config.criticalRepeat > 0
&& isCriticalNotification( value.path, value.value.state) )
{
start_critical_repeat_notification(push_devices, value)
} else if ( repeatingNotifications[value.path] ) {
clearInterval(repeatingNotifications[value.path])
delete repeatingNotifications[value.path]
}
} else if (last_states[value.path] && repeatingNotifications[value.path] && value.value.method.indexOf('sound') === -1) {
clearInterval(repeatingNotifications[value.path])
delete repeatingNotifications[value.path]
}
}
else if ( last_states[value.path] )
{
delete last_states[value.path]
}
} else {
let last = switchStates[value.path]
if ( last === undefined || last !== value.value) {
switchStates[value.path] = value.value
if (last !== undefined ) {
handleControlChange(value)
}
}
}
})
})
}
function start_critical_repeat_notification(devices, value) {
let repeatKey = value.path
if (repeatingNotifications[repeatKey] === undefined) {
let duration = config.criticalRepeatDurationSeconds || 0
let startTime = Date.now()
if ( duration > 180 ) {
duration = 180
}
let interval = setInterval(() => {
let now = Date.now()
if (duration > 0
&& (now - startTime) > (duration * 1000)) {
clearInterval(repeatingNotifications[repeatKey])
delete repeatingNotifications[repeatKey]
} else {
app.debug('repeating critical notification %s', repeatKey)
send_push(app, devices, value.value.message, value.path, value.value.state)
send_local_push(value.value.message, value.path, value.value.state)
}
}, config.criticalRepeat * 1000)
repeatingNotifications[repeatKey] = interval
}
}
function pathForPluginId(app, id, name) {
var dir = app.config.configPath || app.config.appPath
return path.join(dir, "/plugin-config-data", id + "-" + name + '.json')
}
function readJson(app, name, id) {
try
{
const path = pathForPluginId(app, id, name)
const optionsAsString = fs.readFileSync(path, 'utf8');
try {
return JSON.parse(optionsAsString)
} catch (e) {
app.error("Could not parse JSON options:" + optionsAsString);
return {}
}
} catch (e) {
if (e.code && e.code === 'ENOENT') {
return {}
}
app.error("Could not find options for plugin " + id + ", returning empty options")
app.error(e.stack)
return {}
}
return JSON.parse()
}
function saveJson(app, name, id, json, res, cb)
{
fs.writeFile(pathForPluginId(app, id, name), JSON.stringify(json, null, 2),
function(err) {
if (err) {
app.debug(err.stack)
app.error(err)
if ( res ) {
res.status(500)
res.send(err)
}
return
}
else
{
if ( res ) {
res.send("Success\n")
}
if (cb ) {
cb()
}
}
});
}
function send_background_push(app, devices, path, value, widgets)
{
var aps = {
"aps": { "content-available": 1 },
"path": path,
"value": true,
controls: [],
widgets
}
let tokens = devices.map(device => {
return {
token: device.deviceToken,
production: device.targetArn !== undefined
? device.targetArn.indexOf('APNS_SANDBOX') == -1
: device.production
}
})
app.debug('sending background push to tokens %j : %j', tokens, aps)
invokeLambda('sendAlertPush', {
type: 'background',
tokens,
aps,
test: false
})
.then(response => {
app.debug(response.logs)
app.debug(response.result)
})
.catch(err => {
app.error(err)
})
}
function isCriticalNotification(path, state) {
const isEmergency = ((config.emergencyCritical === undefined ||
config.emergencyCritical) && state === 'emergency')
const isAlarm = (config.alarmCritical !== undefined && config.alarmCritical && state === 'alarm')
const isCritical = (config.criticalNotifications && config.criticalNotifications.indexOf(path) !== -1)
return isEmergency || isAlarm || isCritical
}
function get_apns(message, path, state)
{
if ( message.startsWith('Unknown Seatalk Alarm') ) {
return
}
message = `${state.charAt(0).toUpperCase() + state.slice(1)}: ${message}`
const content = {
aps: {
alert: { body: message },
'content-available': 1
},
path: path,
self: app.selfId
}
let name = app.getSelfPath("name")
if ( name ) {
content.aps.alert.title = name
}
let category = (state === 'normal' ? "alarm_normal" : "alarm")
if ( state != 'normal' )
{
if ( path === "notifications.autopilot.PilotWayPointAdvance" )
{
category = 'advance_waypoint'
}
else if ( path === 'notifications.anchorAlarm' || path === 'notifications.navigation.anchor')
{
category = 'anchor_alarm'
}
else if ( path.startsWith('notifications.security.accessRequest') )
{
let parts = path.split('.')
let permissions = parts[parts.length-2]
category = `access_req_${permissions}`
}
} else if ( path === "notifications.autopilot.PilotWayPointAdvance" ) {
return
}
content.aps.category = category
if (isCriticalNotification(path, state)) {
const volume = Math.min(Math.max(parseInt(config.criticalVolume) || 100, 0), 100) / 100.0
content.aps.sound = {
critical: 1,
name: 'default',
volume: volume
}
content['interruption-level'] = 'critical'
} else {
content.aps.sound = 'default'
}
return content
}
function send_push(app, devices, message, path, state)
{
var aps = get_apns(message, path, state)
if ( !aps ) {
return
}
let tokens = devices.map(device => {
return {
token: device.deviceToken,
production: device.targetArn !== undefined
? device.targetArn.indexOf('APNS_SANDBOX') == -1
: device.production
}
})
app.debug('sending alert to tokens %j : %j', tokens, aps)
invokeLambda('sendAlertPush', {
type: 'alert',
tokens,
aps,
test: false
})
.then(response => {
app.debug(response.logs)
app.debug(response.result)
})
.catch(err => {
app.error(err)
})
}
function send_local_push(message, path, state)
{
var aps = get_apns(message, path, state)
if ( aps ) {
if ( aps.aps.alert.title ) {
aps.aps.alert.title = `${aps.aps.alert.title} (Local)`
}
pushSockets.forEach(socket => {
try {
socket.write(JSON.stringify(aps) + '\n')
} catch (err) {
app.error('error sending: ' + err)
}
})
}
}
function start_local_server()
{
const port = config.localPushPort || 3001
server = createServer((socket) => {
socket.id = idSequence++
socket.name = socket.remoteAddress + ':' + socket.remotePort
app.debug('Connected:' + socket.id + ' ' + socket.name)
socket.on('error', (err) => {
app.error(err + ' ' + socket.id + ' ' + socket.name)
})
socket.on('close', hadError => {
app.debug('Close:' + hadError + ' ' + socket.id + ' ' + socket.name)
let idx = pushSockets.indexOf(socket)
if ( idx != -1 ) {
pushSockets.splice(idx, 1)
}
})
socket
.pipe(
split((s) => {
if (s.length > 0) {
try {
return JSON.parse(s)
} catch (e) {
console.log(e.message)
}
}
})
)
.on('data', msg => {
if ( msg.heartbeat ) {
socket.write('{"heartbeat":true}')
} else if ( !msg.deviceName || !msg.deviceToken ) {
app.debug('invalid msg: %j', msg)
socket.end()
} else {
socket.device = msg
pushSockets.push(socket)
app.debug('registered device: %j', msg)
}
})
.on('error', (err) => {
app.error(err)
})
socket.on('end', () => {
app.debug('Ended:' + socket.id + ' ' + socket.name)
})
socket.write(JSON.stringify(app.getHello()) + '\n')
setTimeout(() => {
if ( !socket.device ) {
app.debug('closing socket, no registration received')
socket.end()
}
}, 5000)
})
server.on('listening', () =>
app.debug('local push server listening on ' + port)
)
server.on('error', e => {
app.error(`local push server error: ${e.message}`)
app.setProviderError(`can't start local push server ${e.message}`)
})
if (process.env.TCPSTREAMADDRESS) {
app.debug('Binding to ' + process.env.TCPSTREAMADDRESS)
server.listen(port, process.env.TCPSTREAMADDRESS)
} else {
server.listen(port)
}
}
function deviceIsLocal(device) {
return pushSockets.find(socket => {
if ( device.deviceToken ) {
return socket.device.deviceToken == device.deviceToken
} else {
return socket.device.deviceName == device.deviceName
}
})
}
async function invokeLambda(functionName, payload) {
const client = new LambdaClient(decodedIcon);
const command = new InvokeCommand({
FunctionName: functionName,
Payload: JSON.stringify(payload),
LogType: LogType.Tail,
});
const { Payload, LogResult } = await client.send(command);
const result = Buffer.from(Payload).toString();
const logs = Buffer.from(LogResult, "base64").toString();
return { logs, result };
}
return plugin;
}