pareto-anywhere
Version:
Open source IoT middleware suite that makes the data from just about anything usable. We believe in an open Internet of Things.
733 lines (639 loc) • 26.4 kB
JavaScript
/**
* Copyright reelyActive 2023-2026
* We believe in an open Internet of Things
*/
const DYNAMB_PROPERTY_ICON_CLASSES = {
batteryPercentage: "fas fa-battery-half",
illuminance: "fas fa-sun",
isButtonPressed: "fas fa-hand-pointer",
isCarbonMonoxideDetected: "fas fa-skull-crossbones",
isContactDetected: "fas fa-compress-alt",
isGasDetected: "fas fa-smog",
isInputDetected: "fas fa-check",
isLightDetected: "fas fa-lightbulb",
isLiquidDetected: "fas fa-tint",
isMotionDetected: "fas fa-walking",
isOccupancyDetected: "fas fa-user-check",
isSmokeDetected: "fas fa-fire",
isTamperDetected: "fas fa-exclamation-triangle",
relativeHumidity: "fas fa-water",
temperature: "fas fa-thermometer-half",
text: "fas fa-comment",
unicodeCodePoints: "fas fa-language"
};
const DYNAMB_PROPERTY_UNITS = {
batteryPercentage: "%",
illuminance: " lux",
relativeHumidity: "%",
temperature: "\u2103"
};
const DYNAMB_PROPERTY_PRECISION = {
batteryPercentage: 0,
illuminance: 0,
relativeHumidity: 0,
temperature: 1
};
const DEFAULT_PROPERTY_BOUNDS = {
batteryPercentage: { min: 10, max: 100 },
illuminance: { min: 300, max: 500 },
relativeHumidity: { min: 20, max: 80 },
temperature: { min: 19, max: 23 }
};
const DEFAULT_DYNAMB_PROPERTY_ICON_CLASS = 'fas fa-info';
const CLOCK_OPTIONS = { hour: "2-digit", minute: "2-digit", hour12: false };
const UPDATE_TIME_OPTIONS = { hour: "2-digit", minute: "2-digit",
second: "2-digit", hour12: false };
const DISCRETE_TIMESTAMP_OPTIONS = { hour: "2-digit", minute: "2-digit",
second: "2-digit", hour12: false };
const DISCRETE_DATA_STALE_MILLISECONDS = 60000;
const DISCRETE_DATA_ANIMATE_MILLISECONDS = 15000;
const DEFAULT_DEVICE_FILTER = function(device) { return true; };
/**
* ContinuousDataTable Class
* Manages the processing and rendering of continuous data in an HTML table.
*/
class ContinuousDataTable {
/**
* ContinuousDataTable constructor
* @param {String} elementId The id of the table in the HTML.
* @param {Map} devices The hyperlocal context graph.
* @param {Object} options The options as a JSON object.
* @constructor
*/
constructor(elementId, devices, options) {
let self = this;
options = options || {};
this.table = document.querySelector(elementId);
this.devices = devices;
this.continuousData = new Map();
this.updateMilliseconds = options.updateMilliseconds || 15000;
this.numberOfSamples = options.numberOfSamples || 4;
this.propertiesToDisplay = options.propertiesToDisplay ||
[ 'temperature', 'relativeHumidity',
'illuminance', 'batteryPercentage' ];
this.propertyBounds = options.propertyBounds || DEFAULT_PROPERTY_BOUNDS;
this.propertiesToDisplay.forEach((property) => {
self.continuousData.set(property, { averages: [], numberOfDevices: [],
minimums: [], maximums: [] });
});
this.render();
periodicUpdate();
function periodicUpdate() {
self.update();
setTimeout(periodicUpdate, self.updateMilliseconds);
}
}
/**
* Render the HTML table from scratch.
*/
render() {
let self = this;
let iTime = createElement('i', 'fas fa-clock');
let spanTime = createElement('span');
let caption = createElement('caption', 'text-end', [ iTime, spanTime ]);
let tbody = createElement('tbody');
spanTime.setAttribute('id', 'continuousUpdateTime');
this.propertiesToDisplay.forEach((property) => {
let iconClass = DYNAMB_PROPERTY_ICON_CLASSES[property] ||
DEFAULT_DYNAMB_PROPERTY_ICON_CLASS;
let i = createElement('i', iconClass + ' text-white display-4');
let th = createElement('th', 'bg-dark align-middle', i);
let spanContent = '\u2014' + (DYNAMB_PROPERTY_UNITS[property] || '');
let spanAvg = createElement('span', 'text-body-secondary', spanContent);
let tdAvg = createElement('td', 'align-middle display-4', spanAvg);
let spanMax = createElement('span', 'text-body-secondary', '\u2014');
let spanMin = createElement('span', 'text-body-secondary', '\u2014');
let supMax = createElement('sup', 'text-body-secondary', '\u00a0 max');
let subMin = createElement('sub', 'text-body-secondary', '\u00a0 min');
let liMax = createElement('li', 'list-group-item', [ spanMax, supMax ]);
let liMin = createElement('li', 'list-group-item', [ spanMin, subMin ]);
let ul = createElement('ul', 'list-group list-group-flush',
[ liMax, liMin ]);
let tdRange = createElement('td', 'align-middle', ul);
let tr = createElement('tr', null, [ th, tdAvg, tdRange ]);
tbody.appendChild(tr);
th.setAttribute('id', property + 'Icon');
spanAvg.setAttribute('id', property + 'Display');
spanMax.setAttribute('id', property + 'DisplayMax');
spanMin.setAttribute('id', property + 'DisplayMin');
});
self.table.setAttribute('class', 'table table-bordered text-center');
self.table.appendChild(caption);
self.table.appendChild(tbody);
}
/**
* Update the HTML table.
*/
update() {
let self = this;
let current = {};
let time = new Date().toLocaleTimeString([], UPDATE_TIME_OPTIONS);
let updateTime = document.querySelector('#continuousUpdateTime');
updateTime.textContent = '\u00a0' + time;
self.continuousData.forEach((data, property) => { current[property] = [] });
self.devices.forEach((device) => {
if(device.hasOwnProperty('dynamb')) {
self.continuousData.forEach((data, property) => {
if(device.dynamb.hasOwnProperty(property)) {
current[property].push(device.dynamb[property]);
}
});
}
});
self.continuousData.forEach((data, property) => {
let numberOfDevices = current[property].length;
let precision = DYNAMB_PROPERTY_PRECISION[property];
let avgDisplay = document.querySelector('#' + property + 'Display');
let maxDisplay = document.querySelector('#' + property + 'DisplayMax');
let minDisplay = document.querySelector('#' + property + 'DisplayMin');
let iconCellDisplay = document.querySelector('#' + property + 'Icon');
let isOutOfBounds = false;
let average, minimum, maximum;
if(numberOfDevices > 0) {
average = current[property].reduce((a,b) => a + b, 0) / numberOfDevices;
minimum = Math.min(...current[property]);
maximum = Math.max(...current[property]);
avgDisplay.textContent = average.toFixed(precision);
let isAvgInBounds = (average <= self.propertyBounds[property].max) &&
(average >= self.propertyBounds[property].min);
let avgClass = isAvgInBounds ? 'text-body' : 'text-secondary';
avgDisplay.setAttribute('class', avgClass);
isOutOfBounds ||= !isAvgInBounds;
}
else {
avgDisplay.textContent = '\u2014';
avgDisplay.setAttribute('class', 'text-body-tertiary');
}
avgDisplay.textContent += (DYNAMB_PROPERTY_UNITS[property] || '');
if(numberOfDevices > 1) {
let isMaxInBounds = (maximum <= self.propertyBounds[property].max) &&
(maximum >= self.propertyBounds[property].min);
let isMinInBounds = (minimum <= self.propertyBounds[property].max) &&
(minimum >= self.propertyBounds[property].min);
let maxClass = isMinInBounds ? 'text-body-secondary' : 'text-secondary';
let minClass = isMinInBounds ? 'text-body-secondary' : 'text-secondary';
maxDisplay.textContent = maximum.toFixed(precision) +
(DYNAMB_PROPERTY_UNITS[property] || '');
minDisplay.textContent = minimum.toFixed(precision) +
(DYNAMB_PROPERTY_UNITS[property] || '');
maxDisplay.setAttribute('class', maxClass);
minDisplay.setAttribute('class', minClass);
isOutOfBounds ||= (!isMaxInBounds || !isMinInBounds);
}
else {
maxDisplay.textContent = '\u2014';
minDisplay.textContent = '\u2014';
maxDisplay.setAttribute('class', 'text-body-tertiary');
minDisplay.setAttribute('class', 'text-body-tertiary');
}
let iconCellClass = isOutOfBounds ? 'bg-secondary align-middle' :
'bg-dark align-middle';
iconCellDisplay.setAttribute('class', iconCellClass);
data.averages.unshift(average);
data.minimums.unshift(minimum);
data.maximums.unshift(maximum);
data.numberOfDevices.unshift(numberOfDevices);
if(data.averages.length > self.numberOfSamples) {
data.averages.pop();
data.minimums.pop();
data.maximums.pop();
data.numberOfDevices.pop();
}
});
}
}
/**
* DiscreteDataTable Class
* Manages the processing and rendering of discrete data in an HTML table.
*/
class DiscreteDataTable {
constructor(elementId, options) {
let self = this;
this.table = document.querySelector(elementId);
this.discreteData = new Map();
this.updateMilliseconds = options.updateMilliseconds || 5000;
this.maxRows = options.maxRows || 8;
this.isClockDisplayed = options.isClockDisplayed || false;
this.digitalTwins = options.digitalTwins || new Map();
this.propertiesToDisplay = options.propertiesToDisplay ||
[ 'isButtonPressed', 'isCarbonMonoxideDetected',
'isContactDetected', 'isGasDetected',
'isInputDetected', 'isLightDetected',
'isLiquidDetected', 'isMotionDetected',
'isOccupancyDetected', 'isSmokeDetected',
'isTamperDetected', 'text',
'unicodeCodePoints' ];
this.render();
periodicUpdate();
function periodicUpdate() {
let nextUpdateTime = self.update();
setTimeout(periodicUpdate, Math.max(nextUpdateTime - Date.now(), 1000));
}
}
/**
* Render the HTML table from scratch.
*/
render() {
let self = this;
if(self.isClockDisplayed) {
let time = new Date().toLocaleTimeString([], CLOCK_OPTIONS);
let td = createElement('td', 'bg-dark bg-gradient text-white display-1',
time);
let tr = createElement('tr', null, td);
let thead = createElement('thead', null, tr);
td.setAttribute('colspan', '4');
td.setAttribute('id', 'timeDisplay');
self.table.appendChild(thead);
}
let tbody = createElement('tbody');
tbody.setAttribute('id', 'discreteDataRows');
self.table.setAttribute('class', 'table table-hover text-center');
self.table.appendChild(tbody);
}
/**
* Update the HTML table (time & events).
*/
update() {
let self = this;
let nextMinuteTime = Math.ceil(Date.now() / 60000) * 60000;
let nextUpdateTime = Math.min(Date.now() + self.updateMilliseconds,
nextMinuteTime);
let tbody = document.querySelector('#discreteDataRows');
if(self.isClockDisplayed) {
let time = new Date().toLocaleTimeString([], CLOCK_OPTIONS);
let timeDisplay = document.querySelector('#timeDisplay');
timeDisplay.textContent = time;
}
if(tbody.hasChildNodes()) {
tbody.childNodes.forEach((row, index) => {
if(index >= self.maxRows) {
tbody.removeChild(row);
}
else {
let staleTimestamp = row.timestamp + DISCRETE_DATA_STALE_MILLISECONDS;
let animateTimestamp = row.timestamp +
DISCRETE_DATA_ANIMATE_MILLISECONDS;
if(staleTimestamp < Date.now()) {
row.setAttribute('class', 'align-middle text-body-tertiary');
}
else {
nextUpdateTime = Math.min(staleTimestamp, nextUpdateTime);
}
if(animateTimestamp < Date.now()) {
row.firstChild.setAttribute('class', 'text-body-secondary');
}
else {
nextUpdateTime = Math.min(animateTimestamp, nextUpdateTime);
}
}
});
}
return nextUpdateTime;
}
/**
* Update the table based on new dynambic ambient (dynamb) data.
* @param {Object} dynamb The dynamic ambient data.
*/
handleDynamb(dynamb) {
let self = this;
let tbody = document.querySelector('#discreteDataRows');
let deviceSignature = dynamb.deviceId + '/' + dynamb.deviceIdType;
let digitalTwin = self.digitalTwins.get(deviceSignature) || {};
let deviceName = determineDeviceName(deviceSignature, digitalTwin);
this.propertiesToDisplay.forEach((property) => {
if(dynamb.hasOwnProperty(property)) {
let id = dynamb.deviceId + '-' + dynamb.deviceIdType + '-' + property;
let current = dynamb[property];
let previous = self.discreteData.get(id);
let event = determineDiscreteDataEvent(property, current, previous);
if(event) {
self.discreteData.set(id, current);
let row = document.getElementById(id);
if(row) {
updateDiscreteDataRow(row, event, deviceName, dynamb.timestamp);
}
else {
row = createDiscreteDataRow(id, property, event, deviceSignature,
deviceName, dynamb.timestamp);
if(tbody.hasChildNodes() &&
(tbody.childNodes.length >= self.maxRows)) {
tbody.removeChild(tbody.lastChild);
}
}
tbody.insertBefore(row, tbody.firstChild);
}
}
});
}
/**
* Update the digital twin of the identified entries(s)
*/
updateDigitalTwin(deviceSignature, digitalTwin) {
let deviceName = determineDeviceName(deviceSignature, digitalTwin || {});
let namedNodes = document.getElementsByName(deviceSignature + '-name');
namedNodes.forEach((node) => { node.textContent = deviceName; });
}
}
/**
* DevicesTable Class
* Manages the processing and rendering of devices in an HTML table.
*/
class DevicesTable {
/**
* DevicesTable constructor
* @param {String} elementId The id of the table in the HTML.
* @param {Object} options The options as a JSON object.
* @constructor
*/
constructor(elementId, options) {
let self = this;
options = options || {};
this.table = document.querySelector(elementId);
this.beaver = options.beaver;
this.displayedDevices = new Map();
this.maxRows = options.maxRows || 12;
this.isClockDisplayed = options.isClockDisplayed || false;
this.digitalTwins = options.digitalTwins || new Map();
this.isFilteredDevice = options.isFilteredDevice || DEFAULT_DEVICE_FILTER;
this.selectedDeviceSignature;
this.eventCallbacks = { selection: [] };
this.render();
if(self.beaver) {
self.beaver.on('appearance', (deviceSignature, device) => {
if(self.isFilteredDevice(device)) {
self.insertDevice(deviceSignature, device);
}
});
self.beaver.on('disappearance', (deviceSignature) => {
self.removeDevice(deviceSignature);
});
}
if(self.isClockDisplayed) { updateClock(); }
function updateClock() {
let time = new Date().toLocaleTimeString([], CLOCK_OPTIONS);
let millisecondsToNextMinute = 60000 - (Date.now() % 60000);
document.querySelector('#timeDisplay').textContent = time;
setTimeout(updateClock, millisecondsToNextMinute);
}
}
/**
* Render the HTML table from scratch.
*/
render() {
let self = this;
if(self.isClockDisplayed) {
let time = new Date().toLocaleTimeString([], CLOCK_OPTIONS);
let td = createElement('td', 'bg-dark bg-gradient text-white display-1',
time);
let tr = createElement('tr', null, td);
let thead = createElement('thead', null, tr);
td.setAttribute('colspan', '4');
td.setAttribute('id', 'timeDisplay');
self.table.appendChild(thead);
}
let tbody = createElement('tbody');
tbody.setAttribute('id', 'devicesRows');
self.table.setAttribute('class', 'table table-hover text-center');
self.table.appendChild(tbody);
}
/**
* Insert the given device.
*/
insertDevice(deviceSignature, device) {
let self = this;
let isFullTable = (self.displayedDevices.size >= self.maxRows);
if(!isFullTable && !self.displayedDevices.has(deviceSignature) &&
self.isFilteredDevice(device)) {
let tbody = document.querySelector('#devicesRows');
let digitalTwin = self.digitalTwins.get(deviceSignature) || {};
let deviceName = determineDeviceName(deviceSignature, digitalTwin);
let id = deviceSignature.replace('/', '-') + '-device';
let row = createDeviceRow(id, deviceSignature, deviceName, self);
if(tbody.hasChildNodes() &&
(tbody.childNodes.length >= self.maxRows)) {
let removedDeviceSignature = tbody.lastChild.id.replace('-device', '')
.replace('-', '/');
self.removeDevice(removedDeviceSignature);
}
tbody.insertBefore(row, tbody.firstChild);
self.displayedDevices.set(deviceSignature, device); // TODO value
}
}
/**
* Select the given device.
*/
selectDevice(deviceSignature) {
let self = this;
let id, button;
let isUnselect = (self.selectedDeviceSignature === deviceSignature);
let isReplace = (self.selectedDeviceSignature && !isUnselect);
if(isUnselect || isReplace) {
id = self.selectedDeviceSignature.replace('/', '-') + '-button';
button = document.getElementById(id);
if(button) {
button.setAttribute('class', 'btn btn-sm btn-outline-primary');
}
}
if(deviceSignature && !isUnselect) {
id = deviceSignature.replace('/', '-') + '-button';
button = document.getElementById(id);
if(button) {
button.setAttribute('class', 'btn btn-sm btn-primary');
}
}
self.selectedDeviceSignature = isUnselect ? null : deviceSignature;
self.eventCallbacks.selection.forEach((callback) => {
callback(self.selectedDeviceSignature);
});
}
/**
* Remove the given device.
*/
removeDevice(deviceSignature) {
let self = this;
if(self.displayedDevices.has(deviceSignature)) {
let id = deviceSignature.replace('/', '-') + '-device';
let tbody = document.querySelector('#devicesRows');
let row = document.getElementById(id);
tbody.removeChild(row);
self.displayedDevices.delete(deviceSignature);
}
if(self.selectedDeviceSignature === deviceSignature) {
self.selectDevice(null);
}
}
/**
* Update the digital twin of the identified entries(s)
*/
updateDigitalTwin(deviceSignature, digitalTwin) {
let deviceName = determineDeviceName(deviceSignature, digitalTwin || {});
let namedNodes = document.getElementsByName(deviceSignature + '-name');
namedNodes.forEach((node) => { node.textContent = deviceName; });
}
/**
* Change the maximum number of rows to display
*/
changeMaxRows(maxRows) {
let self = this;
self.maxRows = maxRows;
while(self.displayedDevices.size > maxRows) {
let lastDeviceSignature = [...self.displayedDevices.keys()].at(-1);
if(self.selectedDeviceSignature !== lastDeviceSignature) {
self.removeDevice(lastDeviceSignature);
}
}
}
/**
* Event callbacks.
*/
on(event, callback) {
let isValidEvent = event && this.eventCallbacks.hasOwnProperty(event);
let isValidCallback = callback && (typeof callback === 'function');
if(isValidEvent && isValidCallback) {
this.eventCallbacks[event].push(callback);
}
}
}
// Compare current and previous values of the property to determine event
function determineDiscreteDataEvent(property, current, previous) {
let isEvent = false;
if((previous === undefined) || (property === 'text') ||
(property === 'unicodeCodePoints')) {
isEvent = true;
}
else if(Array.isArray(current) && Array.isArray(previous)) {
isEvent = !((current.length === previous.length) &&
(current.every((value, index) => value === previous[index])));
}
// TODO: other discrete data property types
if(!isEvent) {
return null;
}
switch(property) {
case 'isButtonPressed':
return (current.includes(true) ? 'Button pressed' :
'No button pressed');
case 'isCarbonMonoxideDetected':
return (current.includes(true) ? 'CO detected' :
'No CO detected');
case 'isContactDetected':
return (current.includes(true) ? 'Contact detected' :
'No contact detected');
case 'isGasDetected':
return (current.includes(true) ? 'Gas detected' :
'No gas detected');
case 'isInputDetected':
return (current.includes(true) ? 'Input detected' :
'No input detected');
case 'isLightDetected':
return (current.includes(true) ? 'Light detected' :
'No light detected');
case 'isLiquidDetected':
return (current.includes(true) ? 'Liquid detected' :
'No liquid detected');
case 'isMotionDetected':
return (current.includes(true) ? 'Motion detected' :
'No motion detected');
case 'isOccupancyDetected':
return (current.includes(true) ? 'Occupancy detected' :
'No occupancy detected');
case 'isSmokeDetected':
return (current.includes(true) ? 'Smoke detected' :
'No smoke detected');
case 'isTamperDetected':
return (current.includes(true) ? 'Tamper detected' :
'No tamper detected');
case 'unicodeCodePoints':
let chars = '';
current.forEach(codePoint => chars += String.fromCodePoint(codePoint));
return chars;
default:
return current;
}
}
// Create a discrete data row
function createDiscreteDataRow(id, property, description, deviceSignature,
deviceName, timestamp) {
let iconClass = DYNAMB_PROPERTY_ICON_CLASSES[property] ||
DEFAULT_DYNAMB_PROPERTY_ICON_CLASS;
let icon = createElement('i', iconClass + ' display-6');
let iconCol = createElement('th', 'table-primary animate-breathing', icon);
let deviceCol = createElement('td', 'font-monospace text-body-secondary',
deviceName);
let descriptionClass = (property === 'unicodeCodePoints') ? 'display-6' :
'fw-bold text-body-secondary';
let descriptionCol = createElement('td', descriptionClass, description);
let time = new Date(timestamp).toLocaleTimeString([],
DISCRETE_TIMESTAMP_OPTIONS);
let timestampCol = createElement('td', 'text-body-secondary', time);
let tr = createElement('tr', 'align-middle',
[ iconCol, descriptionCol, deviceCol, timestampCol ]);
deviceCol.setAttribute('name', deviceSignature + '-name');
tr.id = id;
tr.timestamp = timestamp;
return tr;
}
// Update a discrete data row
function updateDiscreteDataRow(row, description, deviceName, timestamp) {
let iconCol = row.children[0];
let descriptionCol = row.children[1];
let deviceCol = row.children[2];
let timestampCol = row.children[3];
iconCol.setAttribute('class', 'table-primary animate-breathing');
descriptionCol.textContent = description;
deviceCol.textContent = deviceName;
timestampCol.textContent = new Date(timestamp).toLocaleTimeString([],
DISCRETE_TIMESTAMP_OPTIONS);
row.timestamp = timestamp;
}
// Create a device row
function createDeviceRow(id, deviceSignature, deviceName, instance) {
let deviceCol = createElement('td', 'font-monospace', deviceName);
let displayIcon = createElement('i', 'fas fa-eye');
let displayButton = createElement('button', 'btn btn-sm btn-outline-primary',
displayIcon);
let displayCol = createElement('td', null, displayButton);
let tr = createElement('tr', 'align-middle', [ deviceCol, displayCol ]);
deviceCol.setAttribute('name', deviceSignature + '-name');
displayButton.id = deviceSignature.replace('/', '-') + '-button';
tr.id = id;
displayButton.addEventListener('click', (event) => {
let selectedDeviceSignature = event.currentTarget.id.replace('-button', '')
.replace('-', '/');
instance.selectDevice(selectedDeviceSignature);
});
return tr;
}
// Determine the device name based on its identifier and digital twin
function determineDeviceName(deviceSignature, digitalTwin) {
let hasStoryCovers = Array.isArray(digitalTwin.storyCovers) &&
(digitalTwin.storyCovers.length > 0);
if(hasStoryCovers) {
return digitalTwin.storyCovers[0].title;
}
return deviceSignature;
}
// Create an element as specified
function createElement(elementName, classNames, content) {
let element = document.createElement(elementName);
if(classNames) {
element.setAttribute('class', classNames);
}
if((content instanceof Element) || (content instanceof DocumentFragment)) {
element.appendChild(content);
}
else if(Array.isArray(content)) {
content.forEach((item) => {
if((item instanceof Element) || (item instanceof DocumentFragment)) {
element.appendChild(item);
}
else {
element.appendChild(document.createTextNode(item));
}
});
}
else if(content) {
element.appendChild(document.createTextNode(content));
}
return element;
}