signalk-notification-player
Version:
Customizable audio and text to speech playback of Signal K notifications w/ optional Slack integration
341 lines (310 loc) • 12.2 kB
JavaScript
//
// signalk-notifcation-player webapp
//
const BASE_URL = '/plugins/signalk-notification-player'
const SELF_URL = '/signalk/v1/api/vessels/self'
//const CONFIG_URL = '/admin/#/serverConfiguration/plugins/signalk-notification-player'
let popupActive = false // keep popup data from reloading when main table reloads
let popupActiveState = false // keep popup data from reloading when main table reloads
let vesselName = ''
//let zones = ''
let listEntries = 0
let updateInterval = 2
let updateTimer
let updateDisable = false
let playbackControlPath = ''
const table = document.createElement('table')
const headerRow = document.createElement('tr')
const bgColorList = {
emergency: '#ff0000',
alarm: '#ff5555',
warn: 'yellow',
alert: 'olive',
normal: '#768670',
nominal: '#9bb194'
}
const listContent = document.getElementById('list-div')
listContent.innerHTML = ''
headerRow.innerHTML =
'<th style="font-size: large">' +
vesselName +
' Notification</th><th>Value</th><th>Age<br></th><th id="showAllNotifications-state">State</th><th>Disable</th><th span=2><button id=silenceAll>Silence All</button></th>'
table.appendChild(headerRow)
listContent.appendChild(table)
document.getElementById(`silenceAll`).addEventListener('click', processSilence)
document.getElementById('playbackDisabled').addEventListener('click', processDisable)
document.getElementById('playbackDisabled').checked = updateDisable
async function getJSON(endpoint) {
try {
const response = await fetch(`${endpoint}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
throw new Error(`Error fetching data: ${response.statusText}`)
}
const data = await response.json()
return data
} catch (error) {
console.error('Error:', error)
return null
}
}
async function processNotification(endpoint) {
try {
const response = await fetch(`${endpoint}`, {
method: 'GET',
headers: {
'Content-Type': 'text/html'
}
})
if (!response.ok) {
throw new Error(`Error fetching data: ${response.statusText}`)
}
const data = await response.text()
return data
} catch (error) {
console.error('Error:', error)
return null
}
}
function updateTimeStamp(status) {
const now = new Date()
if (status) document.getElementById('timestamp').textContent = now.toLocaleTimeString()
else {
const errMsg = document.getElementById('timestamp')
errMsg.innerHTML = '<div style="font-size: x-large; color:#F00;">COMMUNICATION ERROR</div>'
}
}
function updateList(data) {
let age
let statusElement = document.getElementById('overlay')
if (updateDisable == true) {
listContent.style.opacity = '.5'
statusElement.style.display = 'flex'
statusElement.textContent = 'Notification Playback Disabled'
document.getElementById('playbackDisabled').checked = true
} else {
listContent.style.opacity = '1'
statusElement.style.display = 'none'
}
Object.entries(data).forEach(([path, value]) => {
let pathVal, pathUnits, row
if (!(row = document.getElementById('row-' + path))) {
row = document.createElement('tr')
row.setAttribute('id', 'row-' + path)
}
let bgc = bgColorList[value.state]
if (typeof bgc == 'undefined') bgc = '#BBB'
const state = value.state
let bgAge = 'style="color:#DDD"'
let pathTrimmed = path.substring(path.indexOf('.') + 1)
if (typeof value.value !== 'undefined') {
pathVal = value.value
pathUnits = value.units
if (pathVal == null) {
pathVal = 'n/a'
pathUnits = ''
} else if (pathVal == true || pathVal == false) {
pathUnits = ''
} else {
if (!pathUnits) pathUnits = ''
else if (pathUnits == 'K') {
pathUnits = 'C'
pathVal = pathVal - 273.15
}
pathVal = pathVal.toPrecision(3)
if (pathVal == 0.0) pathVal = 0
else if (pathVal == 1.0) pathVal = 1
}
age = (Date.now() - new Date(value.timestamp).getTime()) / 1000
age = Math.trunc(age)
if (age > 60) bgAge = 'style="color: #C04000; font-weight: bold;"'
else if (age > 15) bgAge = 'style="color: #7E3817; font-weight: bold;"'
if (age > 7200) age = Math.trunc(age / 3600) + 'h'
else age = ' ' + age + 's'
} else {
pathVal = 'error'
pathUnits = ''
age = '-'
}
let disabledStyle = ''
if (value.disabled) disabledStyle = 'background-color: #7E3817'
row.innerHTML = `<td id="${pathTrimmed}" style="${disabledStyle}">${pathTrimmed}</td><td>${pathVal} ${pathUnits}</td><td ${bgAge}>${age}</td><td id="${pathTrimmed}-state" bgcolor="${bgc}">${state}</td><td><input id=${path}-disabled type="checkbox"}></td><td><button id="${path}-silence">Silence</button> <button id="${path}-resolve">Resolve</button></td>`
table.appendChild(row)
document.getElementById((path + '-disabled')).checked = value.disabled
})
Object.entries(data).forEach(([path, value]) => {
let pathTrimmed = path.substring(path.indexOf('.') + 1)
if (document.getElementById(`${path}-resolve`))
document.getElementById(`${path}-resolve`).addEventListener('click', processResolve)
if (document.getElementById(`${path}-silence`))
document.getElementById(`${path}-silence`).addEventListener('click', processSilence)
document.getElementById(`${path}-disabled`).addEventListener('click', processPathDisable)
document.getElementById(pathTrimmed).addEventListener('mouseout', function () {
document.getElementById('popupContent').style.display = 'none'
popupActive = false
//fetchAndUpdateList()
})
document.getElementById(pathTrimmed+"-state").addEventListener('mouseout', function () {
document.getElementById('popupContentState').style.display = 'none'
popupActiveState = false
//fetchAndUpdateList()
})
if (!popupActive) {
document.getElementById(pathTrimmed).addEventListener('mouseover', processMouseOver)
}
if (!popupActiveState) {
document.getElementById(pathTrimmed+"-state").addEventListener('mouseover', processMouseOverState)
}
})
document.getElementById("showAllNotifications-state").addEventListener('mouseover', processMouseOverState)
document.getElementById("showAllNotifications-state").addEventListener('mouseout', function () {
document.getElementById('popupContentState').style.display = 'none'
popupActiveState = false
})
}
function processMouseOver(event) {
popupActive = true
let zonePath
if (event.target.id.includes('navigation.anchor'))
zonePath = SELF_URL + '/' + event.target.id.replaceAll('.', '/') + '/meta/value/zones'
// support for anchor api w/ different path?
else zonePath = SELF_URL + '/' + event.target.id.replaceAll('.', '/') + '/meta/zones'
fetch(zonePath)
.then((response) => response.text())
.then((text) => {
text = text.replaceAll('},{', '}<hr>{')
text = text.replace(/[\[\]]/g, '')
if (text.includes('Cannot GET ')) document.getElementById('zones').innerHTML = '---'
else document.getElementById('zones').innerHTML = text
})
.catch((error) => {
//console.error('Error:', error);
})
document.getElementById('popupContent').innerHTML =
'Zone MetaData for: <div style="display:inline; font-size: medium; color:#800;">' +
event.target.id +
'</div><hr><div id=zones>loading zones...</div>'
document.getElementById('popupContent').style.display = 'block'
}
async function processMouseOverState(event) {
let showAll = false
let query
const maxShown = 8
const path = event.target.id.substring(0,event.target.id.indexOf('-'))
popupActiveState = true
document.getElementById('popupContentState').style.display = 'block'
if( path == "showAllNotifications" ) {
showAll = true
query = BASE_URL + '/log?' + maxShown * 2
document.getElementById('popupContentState').innerHTML =
'<div style="display:inline; font-size: small; color:#800;">Most Recent Notifications<hr></div><div id=zonesState>loading...</div>'
} else {
query = BASE_URL + '/log?' + path + "?" + maxShown
document.getElementById('popupContentState').innerHTML =
'Last Notification: <div style="display:inline; font-size: small; color:#800;">' + path +
'</div><hr><div id=zonesState>loading...</div>'
}
getJSON(query).then(data => {
if (data.includes('Cannot GET ')) document.getElementById('zonesState').innerHTML = '---'
let text = JSON.stringify(data)
text = text.replaceAll('},{', '}<hr>{')
text = text.replace(/[\[\]]/g, '')
let html = "<table>"
for (const item of data) {
if(typeof item.value === 'number') item.value = item.value.toPrecision(5)
if(showAll) html += "<tr><td>"+item.path+"</td><td>State: "+item.state+"</td><td>Value: "+item.value+"</td><td>Since: "+formattedDT(new Date(item.datetime))+"</td></tr>";
else html += "<tr><td>State: "+item.state+"</td><td>Value: "+item.value+"</td><td>Since: "+formattedDT(new Date(item.datetime))+"</td></tr>";
}
html += "</table>"
document.getElementById('zonesState').innerHTML = html
});
}
function formattedDT( date ) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
const seconds = date.getSeconds().toString().padStart(2, '0')
return(`${year}/${month}/${day} ${hours}:${minutes}:${seconds}`)
}
async function fetchAndUpdateList() {
fetchStatus()
const data = await getJSON(BASE_URL + '/list')
if (data) {
if (!listEntries)
Object.entries(data).forEach(([path, value]) => {
listEntries++
})
if (listEntries) {
updateList(data)
}
updateTimeStamp(true)
} else updateTimeStamp(false)
}
async function fetchVesselName() {
const data = await getJSON(SELF_URL + '/name')
if (data) vesselName = data + ' :'
}
async function fetchStatus() {
if (playbackControlPath == '') {
// 1st time through get playbackControlPrefix from plugin config
try {
const res1 = await fetch(BASE_URL + '/disable?-1')
const urlpath = await res1.json()
playbackControlPath = SELF_URL + '/' + urlpath.replaceAll('.', '/') + '/disable'
const res2 = await fetch(playbackControlPath)
updateDisable = (await res2.json()).value
} catch (error) {
console.error('Fetching error:', error)
}
} else {
try {
const res2 = await fetch(playbackControlPath)
updateDisable = (await res2.json()).value
} catch (error) {
console.error('Fetching error:', error)
}
}
}
function processResolve(event) {
processNotification(BASE_URL + '/resolve?' + event.target.id.split('-')[0])
fetchAndUpdateList()
}
function processSilence(event) {
if (event.target.id == 'silenceAll') processNotification(BASE_URL + '/silence')
else processNotification(BASE_URL + '/silence?' + event.target.id.split('-')[0])
}
function processDisable(event) {
startTimer() // restart time so page doesn't reload while processing event
if (event.explicitOriginalTarget.checked) {
updateDisable = true
processNotification(BASE_URL + '/disable')
} else {
updateDisable = false
processNotification(BASE_URL + '/disable?0')
}
setTimeout(fetchAndUpdateList, 200)
}
function processPathDisable(event) {
startTimer() // restart timer so page doesn't reload while processing event
processNotification(BASE_URL + '/disablePath?' + event.target.id.split('-')[0] + '?' + event.target.checked)
setTimeout(fetchAndUpdateList, 100)
}
document.getElementById('update-timer').addEventListener('input', (event) => {
updateInterval = parseInt(event.target.value, 10) || 2
startTimer()
})
function startTimer() {
if (updateTimer) clearInterval(updateTimer)
updateTimer = setInterval(fetchAndUpdateList, updateInterval * 1000)
}
// start up
fetchStatus()
fetchVesselName()
fetchAndUpdateList()
startTimer()