Added MQTT Dashboard

This commit is contained in:
robert committed 2023-11-14 05:52:33 -05:00
1 parent a619de0508
commit b0eb7b6dec
14 files changed
+932 -1

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
PWD := $(shell pwd)
all: bundle.js main.css
make -C ../../http-server ARGS="-d $(PWD)"
# Bundle JS libraries (preact, preact-router, ...) into a single file
bundle.js:
curl -s https://npm.reversehttp.com/preact,preact/hooks,htm/preact,preact-router -o $@
# Create optimised CSS. Prerequisite: npm -g i tailwindcss tailwindcss-font-inter
main.css: index.html $(wildcard *.js)
npx tailwindcss -o $@ --minify
clean:
true
+1
View File
@@ -0,0 +1 @@
../../device-dashboard/web_root/bundle.js
+1
View File
@@ -0,0 +1 @@
../../device-dashboard/web_root/components.js
+1
View File
@@ -0,0 +1 @@
../../device-dashboard/web_root/history.min.js
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-white">
<head>
<title></title>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='currentColor'> <path stroke-linecap='round' stroke-linejoin='round' d='M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0' /> </svg>" />
<link href="main.css" rel="stylesheet" />
<link href="https://rsms.me/inter/inter.css" rel="stylesheet" />
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
</head>
<body class="h-full"></body>
<script src="history.min.js"></script>
<script type="module" src="main.js"></script>
</html>
+1
View File
@@ -0,0 +1 @@
/*! tailwindcss v3.3.2 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter var,Helvetica,sans-serif;font-feature-settings:"cv11","ss01";font-variation-settings:"opsz" 32}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.right-4{right:1rem}.top-0{top:0}.top-4{top:1rem}.isolate{isolation:isolate}.z-10{z-index:10}.z-\[48\]{z-index:48}.m-4{margin:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-3{margin-top:.75rem;margin-bottom:.75rem}.my-5{margin-top:1.25rem;margin-bottom:1.25rem}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.mt-1{margin-top:.25rem}.mt-3{margin-top:.75rem}.mt-5{margin-top:1.25rem}.mt-7{margin-top:1.75rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-12{height:3rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-0{width:0}.w-11{width:2.75rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-96{width:24rem}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-none{flex:none}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.flex-grow{flex-grow:1}.translate-x-0{--tw-translate-x:0px}.translate-x-0,.translate-x-5{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-xLine truncated
+532
View File
@@ -0,0 +1,532 @@
'use strict';
import { h, render, useState, useEffect, useRef, html } from './bundle.js';
import { Icons, Setting, Button, tipColors, Colored, Notification} from './components.js';
const Logo = props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12.87 12.85"><defs><style>.ll-cls-1{fill:none;stroke:#000;stroke-miterlimit:10;stroke-width:0.5px;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="ll-cls-1" d="M12.62,1.82V8.91A1.58,1.58,0,0,1,11,10.48H4a1.44,1.44,0,0,1-1-.37A.69.69,0,0,1,2.84,10l-.1-.12a.81.81,0,0,1-.15-.48V5.57a.87.87,0,0,1,.86-.86H4.73V7.28a.86.86,0,0,0,.86.85H9.42a.85.85,0,0,0,.85-.85V3.45A.86.86,0,0,0,10.13,3,.76.76,0,0,0,10,2.84a.29.29,0,0,0-.12-.1,1.49,1.49,0,0,0-1-.37H2.39V1.82A1.57,1.57,0,0,1,4,.25H11A1.57,1.57,0,0,1,12.62,1.82Z"/><path class="ll-cls-1" d="M10.48,10.48V11A1.58,1.58,0,0,1,8.9,12.6H1.82A1.57,1.57,0,0,1,.25,11V3.94A1.57,1.57,0,0,1,1.82,2.37H8.9a1.49,1.49,0,0,1,1,.37l.12.1a.76.76,0,0,1,.11.14.86.86,0,0,1,.14.47V7.28a.85.85,0,0,1-.85.85H8.13V5.57a.86.86,0,0,0-.85-.86H3.45a.87.87,0,0,0-.86.86V9.4a.81.81,0,0,0,.15.48l.1.12a.69.69,0,0,0,.13.11,1.44,1.44,0,0,0,1,.37Z"/></g></g></svg>`;
const url = 'ws://broker.hivemq.com:8000/mqtt'
const default_topic = 'topic_mg_device'
let client;
function Header( {topic, setTopicFn} ) {
const [inputValue, setInputValue] = useState(topic);
const [saveResult, setSaveResult] = useState(null);
const onInput = (ev) => setInputValue(ev.target.value);
const forbiddenChars = ['$', '*', '+', '#', '/'];
const onClick = () => {
const isValidTopic = (value) => {
return !forbiddenChars.some(char => value.includes(char));
};
if (isValidTopic(inputValue)) {
localStorage.setItem('topic', inputValue)
setTopicFn(inputValue);
window.location.reload();
} else {
setSaveResult("Error: The topic cannot contain these characters: " + forbiddenChars)
}
};
return html`
<div class="bg-white sticky top-0 z-[48] w-full border-b-2 border-gray-300 py-3 shadow-md transition-all duration-300 transform">
<div class="mx-auto flex justify-between items-center px-5">
<div class="text-gray-800 font-semibold tracking-wide">
<h1 class="text-2xl">MQTT Dashboard</h1>
</div>
<div class="flex space-x-4">
<div class="text-lg font-medium text-gray-700 text-center">
<div class="text-gray-500">MQTT Server:</div>
<code class="bg-gray-100 font-normal text-sm rounded w-full flex-1 py-0.5 px-2 text-gray-700" style="font-size: 0.8em; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 1px; background-color: #f9f9f9; border: 1px solid #ddd; border-radius: 5px;">
${url}
</code>
</div>
<div style="border-left: 1px solid #ddd; height: 50px;"></div>
<div class="text-lg font-medium text-gray-700 text-center">
<div class="text-gray-500">Topic:</div>
<div class="flex w-full items-center rounded border shadow-sm bg-white">
<input type="text" value=${inputValue} onchange=${onInput} step="1" class="bg-white font-normal text-sm rounded w-full flex-1 py-0.5 px-2 text-gray-700 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500"/>
</div>
</div>
<div class="mb-1 mt-3 flex place-content-end"><${Button} icon=${Icons.save} onclick=${onClick} title="Change topic" /><//>
${saveResult && html`<${Notification} ok=${saveResult === "Success!"}
text=${saveResult} close=${() => setSaveResult(null)} />`}
</div>
</div>
</div>
`;
};
function Devices({ devices, onClickFn }) {
const Td = props => html`
<td class="whitespace-nowrap border-b border-slate-200 py-2 px-4 pr-3 text-sm text-slate-900">${props.text}</td>`;
const Device = ({ d }) => html`
<tr class="hover:bg-slate-100 cursor-pointer" onClick=${() => onClickFn(d.id)}>
<td class="border-b-2 border-slate-300 py-2 text-center align-middle">
<div class="flex flex-col items-center justify-center h-full">
<span>ID ${d.id}</span>
<${Colored} colors=${d.online ? tipColors.green : tipColors.red} text=${d.online ? 'online' : 'offline'} />
</div>
</td>
</tr>`;
return html`
<div class="m-4 divide-y divide-gray-200 overflow-auto rounded bg-white">
<div class="font-semibold flex items-center text-gray-600 px-3 justify-center whitespace-nowrap">
<div class="font-bold flex items-center text-gray-600">
Devices list
</div>
<//>
<div class="inline-block min-w-full align-middle" style="max-height: 82vh; overflow: auto;">
<table class="w-full table table-bordered">
<tbody>
${(devices ? devices : []).map(d => h(Device, { d }))}
</tbody>
</table>
</div>
</div>`;
};
function FirmwareStatus({title, info, children}) {
const state = ['UNAVAILABLE', 'FIRST_BOOT', 'NOT_COMMITTED', 'COMMITTED'][(info.status || 0) % 4];
const valid = info.status > 0;
return html`
<div class="bg-white divide-y border rounded">
<div class="font-light bg-white uppercase flex items-center text-gray-600 px-4 py-2">
${title}
<//>
<div class="px-4 py-2 relative">
<div class="my-1">Status: ${state}<//>
<div class="my-1">CRC32: ${valid ? info.crc32.toString(16) : 'n/a'}<//>
<div class="my-1">Size: ${valid ? info.size : 'n/a'}<//>
<div class="my-1">Flashed at: ${valid ? new Date(info.timestamp * 1000).toLocaleString() : 'n/a'}<//>
${children}
<//>
<//>`;
};
function UploadFileButton(props) {
const [upload, setUpload] = useState(null); // Upload promise
const [status, setStatus] = useState({}); // Current upload status
const btn = useRef(null);
const input = useRef(null);
const setStatusByID = function(message, id) {
setStatus(prvStatus => ({...prvStatus, [id]: message}))
};
// Send a large file chunk by chunk
const sendFileData = function(fileName, fileData, chunkSize) {
return new Promise(function(resolve, reject) {
const finish = ok => {
setUpload(null);
const res = props.onupload ? props.onupload(ok, fileName, fileData.length) : null;
if (res && typeof (res.catch) === 'function') {
res.catch(() => false).then(() => ok ? resolve() : reject());
} else {
ok ? resolve() : reject();
}
};
const sendChunk = function(offset) {
var chunk = fileData.subarray(offset, offset + chunkSize) || '';
var ok;
setStatusByID('Uploading ' + fileName + ', bytes ' + offset + '..' +
(offset + chunk.length) + ' of ' + fileData.length, props.id);
const params = {chunk: btoa(String.fromCharCode.apply(null, chunk)), offset: offset, total: fileData.length}
props.publishFn("ota.upload", params)
.then(function(res) {
if (res.result === "ok" && chunk.length > 0) sendChunk(offset + chunk.length);
ok = res.result === "ok";
return res;
})
.then(function(res) {
if (!ok) setStatusByID('Error: ' + res.error, props.id), finish(ok); // Fail
if (chunk.length > 0) return; // More chunks to send
setStatus(x => x + '. Done, resetting device...');
finish(ok); // All chunks sent
})
.catch(e => {
setStatusByID("Error: timed out", props.id);
finish(false)
});
};
//setFailed(false);
sendChunk(0);
});
};
const onchange = function(ev) {
if (!ev.target.files[0]) return;
let r = new FileReader(), f = ev.target.files[0];
r.readAsArrayBuffer(f);
r.onload = function() {
setUpload(sendFileData(f.name, new Uint8Array(r.result), 2048));
ev.target.value = '';
ev.preventDefault();
btn && btn.current.base.click();
};
};
const onclick = function(ev) {
let fn; setUpload(x => fn = x);
if (!fn) input.current.click(); // No upload in progress, show file dialog
return fn;
};
if (props.clean) {
setStatusByID(null, props.id)
props.setCleanFn(false)
}
return html`
<div class="inline-flex flex-col ${props.class}">
<input class="hidden" type="file" ref=${input} onchange=${onchange} accept=${props.accept} />
<${Button} title=${props.title} icon=${Icons.download} onclick=${onclick} ref=${btn} colors=${props.colors} disabled=${props.disabled} />
<div class="py-2 text-sm text-slate-400 ${status || 'hidden'}">${status[props.id]}<//>
<//>`;
};
function FirmwareUpdate({ publishFn, disabled, info, deviceID }) {
const [clean, setClean] = useState(false)
const refresh = () => {};
useEffect(refresh, []);
const oncommit = ev => { publishFn("ota.commit") };
const onreboot = ev => { publishFn("device.reset")};
const onrollback = ev => { publishFn("ota.rollback") };
const onerase = ev => {};
const onupload = function(ok, name, size) {
if (!ok) return false;
return new Promise(r => setTimeout(ev => { refresh(); r(); }, 3000)).then(r => setClean(true));
};
const defaultInfo = {status: 0, crc32: 0, size: 0, timestamp: 0}
return html`
<div class="bg-slate-200 border rounded-md text-ellipsis overflow-auto">
<div class="bg-slate-100 px-4 py-2 flex items-center justify-between font-light uppercase text-gray-600">
Over-the-air firmware updates
</div>
<div class="gap-1 grid grid-cols-3 lg:grid-cols-3">
<${FirmwareStatus} title="Current firmware image" info=${info[0] ? info[0] : defaultInfo}>
<div class="flex flex-wrap gap-2">
<${Button} title="Commit this firmware" onclick=${oncommit}
icon=${Icons.thumbUp} disabled=${disabled} cls="w-full" />
<//>
<//>
<${FirmwareStatus} title="Previous firmware image" info=${info[1] ? info[1] : defaultInfo}>
<${Button} title="Rollback to this firmware" onclick=${onrollback}
icon=${Icons.backward} disabled=${disabled} cls="w-full" />
<//>
<div class="bg-white xm-4 divide-y border rounded flex flex-col">
<div class="font-light uppercase flex items-center text-gray-600 px-4 py-2">
Device control
<//>
<div class="px-4 py-3 flex flex-col gap-2 grow">
<${UploadFileButton}
title="Upload new firmware: choose .bin file:" publishFn=${publishFn} onupload=${onupload} clean=${clean} setCleanFn=${setClean} disabled=${disabled}
id=${deviceID} url="api/firmware/upload" accept=".bin,.uf2" />
<div class="grow"><//>
<${Button} title="Reboot device" onclick=${onreboot} icon=${Icons.refresh} disabled=${disabled} cls="w-full" />
<${Button} title="Erase last sector" onclick=${onerase} icon=${Icons.doc} disabled=${disabled} cls="w-full hidden" />
<//>
<//>
<//>
<//>`;
};
function Config( {deviceData, setDeviceConfig, publishFn} ) {
const [localConfig, setLocalConfig] = useState();
const [saveResult, setSaveResult] = useState(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (deviceData) {
if (deviceData.config) {
setLocalConfig(deviceData.config)
} else {
let config = {}
setLocalConfig(config)
}
}
}, [deviceData]);
const logOptions = [[0, 'Disable'], [1, 'Error'], [2, 'Info'], [3, 'Debug']];
const mksetfn = k => (v => setLocalConfig(x => Object.assign({}, x, { [k]: v })));
const onSave = () => {
setSaving(true)
publishFn("config.set", localConfig).then(r => {
setDeviceConfig(deviceData.id, localConfig)
setSaveResult("Success!")
setSaving(false)
}).catch(e => {
setDeviceConfig(deviceData.id, deviceData.config)
setSaveResult("Failed!")
setSaving(false)
})
};
if (!deviceData || !localConfig) {
return ``;
}
return html`
<div class="m-4 space-y-2">
<h3 class="text-lg font-semibold mb-3">Device ${deviceData.id} Configuration Panel</h3>
<div class="divide-y border rounded bg-slate-200">
<div class="bg-slate-100 px-4 py-2 flex items-center font-light uppercase text-gray-600">
<span class="mr-2">
Status: ${html`<${Colored} colors=${deviceData.online ? tipColors.green : tipColors.red} text=${deviceData.online ? 'online' : 'offline'} />`}
</span>
<//>
<div class="grid grid-cols-2 gap-1">
<div class="py-1 divide-y border rounded bg-white flex flex-col">
<div class="font-light uppercase flex items-center text-gray-600 px-4 py-2">
LED Settings
<//>
<div class="py-2 px-5 flex-1 flex flex-col relative">
<${Setting} title="LED status" value=${localConfig.led_status} setfn=${mksetfn('led_status')} type="switch" disabled=${!deviceData.online} />
<${Setting} title="LED Pin" type="number" value=${localConfig.led_pin} setfn=${mksetfn('led_pin')} disabled=${!deviceData.online} />
<//>
</div>
<div class="py-1 divide-y border rounded bg-white flex flex-col">
<div class="font-light uppercase flex items-center text-gray-600 px-4 py-2">
Log & Display
<//>
<div class="py-2 px-5 flex-1 flex flex-col relative">
<${Setting} title="Log Level" type="select" value=${localConfig.log_level} setfn=${mksetfn('log_level')} options=${logOptions} disabled=${!deviceData.online}/>
<${Setting} title="Brightness" type="number" value=${localConfig.brightness} setfn=${mksetfn('brightness')} disabled=${!deviceData.online} />
<div class="mt-3 flex justify-end">
${saveResult && html`<${Notification} ok=${saveResult === "Success!"}
text=${saveResult} close=${() => setSaveResult(null)} />`}
<${Button} icon=${Icons.save} onclick=${onSave} title=${saving ? "Saving..." : "Save Settings"} disabled=${!deviceData.online || saving} />
</div>
<//>
</div>
</div>
</div>
<${FirmwareUpdate} deviceID=${deviceData.id} publishFn=${publishFn} disabled=${!deviceData.online} info=${[localConfig.crnt_fw, localConfig.prev_fw]} />
</div>`;
}
const App = function() {
const [devices, setDevices] = useState([]);
const [currentDevID, setCurrentDevID] = useState(localStorage.getItem('currentDevID') || '');
const [loading, setLoading] = useState(true);
const [topic, setTopic] = useState(localStorage.getItem('topic') || default_topic);
const [error, setError] = useState(null);
const responseHandlers = useRef({});
let connSuccessful = false;
function addResponseHandler(correlationId, handler) {
responseHandlers[correlationId] = handler;
}
function removeResponseHandler(correlationId) {
delete responseHandlers[correlationId];
}
const onRefresh = () => {
window.location.reload();
}
const initConn = () => {
client = mqtt.connect(url, {
connectTimeout: 5000,
reconnectPeriod: 0
});
client.on('connect', () => {
console.log('Connected to the broker');
setLoading(false);
setError(null); // Reset error state upon successful connection
connSuccessful = true;
const statusTopic = topic + '/+/status'
const txTopic = topic + '/+/tx'
const subscribe = (topic) => {
client.subscribe(topic, (err) => {
if (err) {
console.error('Error subscribing to topic:', err);
setError('Error subscribing to topic');
} else {
console.log('Successfully subscribed to ', topic);
}
});
};
subscribe(statusTopic)
subscribe(txTopic)
});
client.on('message', (topic, message) => {
//console.log(`Received message from ${topic}: ${message.toString()}`);
let response;
try {
response = JSON.parse(message.toString());
} catch (err) {
console.error(err)
return;
}
if (topic.endsWith("/status")) {
const deviceID = topic.split('/')[1]
let device = {};
device.id = deviceID;
const params = response.params
if (!params) {
console.error("Invalid response")
return
}
device.online = params.status === "online"
if (device.online) {
device.config = {}
device.config.led_status = params.led_status
device.config.led_pin = params.led_pin
device.config.brightness = params.brightness
device.config.log_level = params.log_level
device.config.crnt_fw = params.crnt_fw
device.config.prev_fw = params.prev_fw
}
setDevice(device)
} else if (topic.endsWith("/tx")) {
if (!response.id) {
console.error("Invalid response")
return
}
const handler = responseHandlers[response.id];
if (handler) {
handler(response);
removeResponseHandler(response.id);
}
}
});
client.on('error', (err) => {
console.error('Connection error:', err);
setError('Connection cannot be established.');
});
client.on('close', () => {
if (!connSuccessful) {
console.error('Failed to connect to the broker.');
setError('Connection cannot be established.');
setLoading(false);
}
});
};
useEffect(() => {
initConn();
}, []);
const handlePublish = (methodName, parameters, timeout = 5000) => {
return new Promise((resolve, reject) => {
const randomIdGenerator = function(length) {
return Math.random().toString(36).substring(2, length+2);
};
const randomID = randomIdGenerator(40);
const timeoutID = setTimeout(() => {
removeResponseHandler(randomID);
reject(new Error('Request timed out'));
}, timeout);
addResponseHandler(randomID, (messageData) => {
clearTimeout(timeoutID);
resolve(messageData);
});
if (currentDevID) {
const rxTopic = topic + `/${currentDevID}/rx`;
const rpcPayload = {
method: methodName,
id: randomID
};
if (parameters) {
rpcPayload.params = parameters;
}
client.publish(rxTopic, JSON.stringify(rpcPayload));
}
});
};
const getDeviceByID = (deviceID) => {
return devices.find(d => d.id === deviceID);
}
const setDevice = (devData) => {
setDevices(prevDevices => {
const devIndex = prevDevices.findIndex(device => device.id === devData.id);
if (devIndex !== -1) {
if (!devData.online && !devData.config) {
const updatedDevices = [...prevDevices];
updatedDevices[devIndex].online = false;
return updatedDevices;
} else {
return prevDevices.map(device => device.id === devData.id ? devData : device);
}
} else {
return [...prevDevices, devData];
}
});
};
const setDeviceConfig = (deviceID, config) => {
setDevices(prevDevices => {
return prevDevices.map(device => {
if (device.id === deviceID) {
return {
...device,
config: config
};
}
return device;
});
});
};
const onDeviceClick = (deviceID) => {
const device = getDeviceByID(deviceID);
if (device) {
setCurrentDevID(device.id);
localStorage.setItem('currentDevID', device.id);
}
}
if (error) {
return html`
<div class="min-h-screen bg-slate-100 flex flex-col">
<${Header}/>
<div class="flex-grow flex items-center justify-center">
<div class="bg-slate-300 p-8 rounded-lg shadow-md w-full max-w-md">
<h1 class="text-2xl font-bold text-gray-700 mb-4 text-center">Connection Error</h1>
<p class="text-gray-600 text-center mb-4">
Unable to connect to the MQTT broker.
</p>
<div class="text-center relative">
<${Button} title="Retry" onclick=${onRefresh} icon=${Icons.refresh} class="absolute top-4 right-4" />
</div>
</div>
</div>
</div>`;
}
return html`
<div class="min-h-screen bg-slate-100 flex flex-col space-y-2">
<${Header} topic=${topic} setTopicFn=${setTopic}/>
<div class="transition-all duration-300 transform flex flex-grow gap-4">
<div class="flex-none bg-white rounded shadow ">
<${Devices} devices=${devices} onClickFn=${onDeviceClick} />
<//>
<div class="flex-1 flex-grow bg-white rounded shadow h-full">
<${Config} deviceData=${getDeviceByID(currentDevID)} setDeviceConfig=${setDeviceConfig} publishFn=${handlePublish}/>
<//>
<//>
<//>`;
};
window.onload = () => render(h(App), document.body);
@@ -0,0 +1,17 @@
module.exports = {
content: ['./*.{html,js}'],
xplugins: [ 'tailwindcss', 'xautoprefixer' ],
corePlugins: {outline: false},
theme: {
extend: {},
fontFamily: {
sans:
[
"Inter var, Helvetica, sans-serif", {
fontFeatureSettings: '"cv11", "ss01"',
fontVariationSettings: '"opsz" 32',
}
]
}
}
}