mirror of
https://github.com/cesanta/mongoose
synced 2026-08-25 22:26:06 -04:00
Simplify dashboard
This commit is contained in:
parent
c08c1afb1e
commit
8a9b1d9873
8 changed files with 231 additions and 452 deletions
|
|
@ -3,7 +3,7 @@ all:
|
|||
|
||||
# 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 $@
|
||||
curl -s https://npm.reversehttp.com/preact,preact/hooks,htm/preact,preact-router,@preact/signals-core,@preact/signals -o $@
|
||||
|
||||
# Create optimised CSS. Prerequisite: npm -g i tailwindcss tailwindcss-font-inter
|
||||
NPX ?= npx
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
../../../http/device-dashboard/web_root/bundle.js
|
||||
1
tutorials/mqtt/mqtt-dashboard/dashboard/bundle.js
Normal file
1
tutorials/mqtt/mqtt-dashboard/dashboard/bundle.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -6,8 +6,11 @@
|
|||
<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>" />
|
||||
<!-- For optimised CSS, re-enable main.css and disable cdn.tailwindcss.com -->
|
||||
<!--
|
||||
<link href="main.css" rel="stylesheet" />
|
||||
<link href="https://rsms.me/inter/inter.css" rel="stylesheet" />
|
||||
-->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/mqtt/dist/mqtt.min.js"></script>
|
||||
</head>
|
||||
<body class="h-full"></body>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,18 +1,16 @@
|
|||
'use strict';
|
||||
import {h, html, render, useEffect, useRef, useState} from './bundle.js';
|
||||
import {h, html, render, useEffect, useRef, useSignal} from './bundle.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 DefaultTopic = 'mg_mqtt_dashboard';
|
||||
const DefaultUrl = location.protocol == 'https:'
|
||||
? 'wss://broker.hivemq.com:8884/mqtt'
|
||||
: 'ws://broker.hivemq.com:8000/mqtt';
|
||||
const DefaultDeviceConfig = {pin_map: [], pin_state: [], log_level: 0, pin_count: 0};
|
||||
const Delay = (ms, val) => new Promise(resolve => setTimeout(resolve, ms, val));
|
||||
// const Delay = (ms, val) => new Promise(resolve => setTimeout(resolve, ms, val));
|
||||
const handleFetchError = r => r.ok || alert(`Error: ${r.statusText}`);
|
||||
const LabelClass = 'text-sm truncate text-gray-500 font-medium my-auto whitespace-nowrap';
|
||||
const LabelClass = 'text-sm truncate font-medium my-auto whitespace-nowrap';
|
||||
const BadgeClass = 'flex-inline text-sm rounded-md rounded px-2 py-0.5 ring-1 ring-inset';
|
||||
const InputClass = '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 rounded border';
|
||||
const TitleClass = 'font-semibold xuppercase xtext-gray-600';
|
||||
const TitleClass = 'font-semibold';
|
||||
const Colors = {
|
||||
green: 'bg-green-100 text-green-900 ring-green-300',
|
||||
yellow: 'bg-yellow-100 text-yellow-900 ring-yellow-300',
|
||||
|
|
@ -22,109 +20,46 @@ const Colors = {
|
|||
|
||||
let MqttClient;
|
||||
|
||||
const Icons = {
|
||||
logo: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="59.1" height="75.7" viewBox="0 0 1514 1182"> <path style="fill: #2f409a;" d="m52.8 572h345.1v346.5h-345.1zm-10.9-434.2c-12.6 6.4-32.2 20.9-35.5 37.9 0 0-6.1 35.9-6.1 36.8-2.1 13.1 10.5 10 15.8 6.7 53.9-31.8 113.9-60.7 168.7-77.5 258.2-78.2 585.9-80.2 643.1 88.8 40.3 117.1-123.6 214.8-216.1 258.4-35.7 16.8-47.6 32.8-50 49.8-2 15.3 3.2 37.8 23.8 68.3 129.7 194.8 515 474 893.1 571.7 15.7 4.2 23.5 2.8 29.9-1.1 3.5-2 7.9-4.8 1.1-8.2-251.1-132.2-490.9-353.9-610.9-525.1-27.4-39.5-34.5-65.9-32.7-87.2 1.7-18.4 18.7-34.5 54.8-51.4 134.1-62.3 270.9-157.1 230-314.9-59.5-226.9-700.5-260.1-1109-53z"/></svg>`,
|
||||
activity: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>`,
|
||||
refresh: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>`,
|
||||
info: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>`,
|
||||
login: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path><polyline points="10 17 15 12 10 7"></polyline><line x1="15" y1="12" x2="3" y2="12"></line></svg>`,
|
||||
logout: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>`,
|
||||
menu: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>`,
|
||||
upload: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>`,
|
||||
monitor: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>`,
|
||||
settings: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>`,
|
||||
download: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>`,
|
||||
file: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path><polyline points="13 2 13 9 20 9"></polyline></svg>`,
|
||||
check: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
|
||||
rollback: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="11 17 6 12 11 7"></polyline><polyline points="18 17 13 12 18 7"></polyline></svg>`,
|
||||
save: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>`,
|
||||
bolt: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>`,
|
||||
delete: props => html`<svg class=${props.class} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
|
||||
};
|
||||
const Help = () => html`
|
||||
<div class="p-2 w-96 text-slate-600 bg-slate-50 overflow-auto text-sm">
|
||||
<div>This is a simple demonstration of the functional device dashboard
|
||||
that manages a fleet of devices via an MQTT server. Source code
|
||||
is <a class="text-blue-600" href="https://github.com/cesanta/mongoose/tree/master/tutorials/mqtt/mqtt-dashboard">available on GitHub<//>.<//>
|
||||
<div class="py-1">
|
||||
For the sake of simplicity, this dashboard does not implement authentication.
|
||||
No external storage is used either to keep device list: for that, retained
|
||||
MQTT messages are utilised. When a device goes online, it publishes its
|
||||
state to the {root_topic}/{device_id}/status topic
|
||||
- LED status and firmware version.
|
||||
<//>
|
||||
<div class="py-1">
|
||||
The last will message triggers
|
||||
the "offline" message to the same topic. This is how this web page
|
||||
is able to track online/offline status of devices.
|
||||
<//>
|
||||
<div class="py-1">
|
||||
See developer console for the list of MQTT messages exchanged with
|
||||
the MQTT server.
|
||||
<//>
|
||||
<//>`;
|
||||
|
||||
export function Button({title, onclick, disabled, extraClass, icon, ref, colors, hovercolor, disabledcolor}) {
|
||||
const [spin, setSpin] = useState(false);
|
||||
export function Button({title, onclick, disabled, extraClass, ref, colors}) {
|
||||
const sigSpin = useSignal(false);
|
||||
const cb = function(ev) {
|
||||
const res = onclick ? onclick() : null;
|
||||
if (res && typeof (res.catch) === 'function') {
|
||||
setSpin(true);
|
||||
res.catch(() => false).then(() => setSpin(false));
|
||||
sigSpin.value = true;
|
||||
res.catch(() => false).then(() => sigSpin.value = false);
|
||||
}
|
||||
};
|
||||
if (!colors) colors = 'bg-blue-600 hover:bg-blue-500 disabled:bg-blue-400';
|
||||
return html`
|
||||
<button type="button" class="inline-flex justify-center items-center gap-2 rounded px-2.5 py-0.5 text-sm font-semibold text-white shadow-sm ${colors} ${extraClass}"
|
||||
ref=${ref} onclick=${cb} disabled=${disabled || spin} >
|
||||
<button type="button" class="inline-flex justify-center items-center gap-2 rounded px-2.5 py-0.5 text-sm font-semibold text-white shadow-sm ${colors} ${extraClass} ${sigSpin.value ? 'animate-pulse' : ''}"
|
||||
ref=${ref} onclick=${cb} disabled=${disabled || sigSpin.value} >
|
||||
${title}
|
||||
<${spin ? Icons.refresh : icon} class="w-4 ${spin ? 'animate-spin' : ''}" />
|
||||
<//>`
|
||||
};
|
||||
|
||||
function UploadFileButton({oncomplete, onprogress, onfileselect, url, title, colors, accept, extraClass}) {
|
||||
const btn = useRef(null);
|
||||
const input = useRef(null);
|
||||
|
||||
// Send a large file chunk by chunk
|
||||
const sendFileData = function(url, fileName, fileData, chunkSize) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
const finish = ok => {
|
||||
input.fn = null;
|
||||
const res = oncomplete ? oncomplete(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 opts = {method: 'POST', body: chunk};
|
||||
var fullUrl = url + '?offset=' + offset +
|
||||
'&total=' + fileData.length +
|
||||
'&file=' + encodeURIComponent(fileName);
|
||||
onprogress && onprogress(offset, fileData.length, fileName);
|
||||
fetch(fullUrl, opts)
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
finish(false);
|
||||
} else if (offset >= fileData.length) {
|
||||
finish(true);
|
||||
} else {
|
||||
sendChunk(offset + chunk.length);
|
||||
}
|
||||
});
|
||||
};
|
||||
sendChunk(0);
|
||||
});
|
||||
};
|
||||
|
||||
const onchange = function(ev) {
|
||||
if (!ev.target.files[0]) return;
|
||||
let r = new FileReader(), f = ev.target.files[0];
|
||||
if (onfileselect && !onfileselect(f.name)) {
|
||||
ev.target.value = '';
|
||||
return;
|
||||
}
|
||||
r.readAsArrayBuffer(f);
|
||||
r.onload = function() {
|
||||
input.fn = sendFileData(url, f.name, new Uint8Array(r.result), 2048);
|
||||
ev.target.value = '';
|
||||
ev.preventDefault();
|
||||
btn.current && btn.current.base.click();
|
||||
};
|
||||
};
|
||||
|
||||
const onclick = function(ev) {
|
||||
//if (!input.fn) input.current.click(); // No upload in progress, show file dialog
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="inline-flex flex-col ${extraClass}">
|
||||
<input class="hidden" type="file" ref=${input} onchange=${onchange} accept=${accept} />
|
||||
<${Button} title=${title} icon=${Icons.download} onclick=${onclick} ref=${btn} colors=${colors} />
|
||||
<//>`;
|
||||
};
|
||||
|
||||
function Toggle({value, onclick, disabled}) {
|
||||
const bg = !!value ? 'bg-blue-600' : 'bg-gray-200';
|
||||
const tr = !!value ? 'translate-x-5' : 'translate-x-0';
|
||||
|
|
@ -136,13 +71,13 @@ function Toggle({value, onclick, disabled}) {
|
|||
</button>`;
|
||||
};
|
||||
|
||||
function Header({topic, setTopic, url, setUrl, connected}) {
|
||||
function Header({sigTopic, sigUrl, connected}) {
|
||||
const forbiddenChars = ['$', '*', '+', '#', '/'];
|
||||
const onClick = () => {
|
||||
const isValidTopic = val => !forbiddenChars.some(char => val.includes(char));
|
||||
if (isValidTopic(topic)) {
|
||||
localStorage.setItem('topic', topic)
|
||||
setTopic(topic);
|
||||
sigTopic.value = topic;
|
||||
window.location.reload();
|
||||
} else {
|
||||
setSaveResult('Error: The topic cannot contain these characters: ' + forbiddenChars);
|
||||
|
|
@ -150,7 +85,7 @@ function Header({topic, setTopic, url, setUrl, connected}) {
|
|||
};
|
||||
|
||||
return html`
|
||||
<div class="top-0 py-2 border-b-2 bg-stone-100">
|
||||
<div class="py-2 bg-slate-700 text-slate-50">
|
||||
<div class="flex px-4">
|
||||
<div class="flex grow gap-4">
|
||||
<h1 class="text-2xl font-semibold">IoT Fleet Management Dashboard</h1>
|
||||
|
|
@ -158,15 +93,15 @@ function Header({topic, setTopic, url, setUrl, connected}) {
|
|||
<div class="flex gap-2 items-center">
|
||||
<div class="flex w-96 align-center justify-between gap-2">
|
||||
<span class=${LabelClass}>MQTT Server<//>
|
||||
<input disabled=${!connected} value=${url}
|
||||
onchange=${ev => setUrl(ev.target.value)} class=${InputClass} />
|
||||
<input disabled=${!connected} value=${sigUrl.value}
|
||||
onchange=${ev => sigUrl.value = ev.target.value} class=${InputClass} />
|
||||
<//>
|
||||
<div class="flex w-64 align-center justify-between gap-2">
|
||||
<span class=${LabelClass}>Root Topic<//>
|
||||
<input disabled=${!connected} value=${topic}
|
||||
onchange=${ev => setTopic(ev.target.value)} class=${InputClass} />
|
||||
<input disabled=${!connected} value=${sigTopic.value}
|
||||
onchange=${ev => sigTopic.value = ev.target.value} class=${InputClass} />
|
||||
<//>
|
||||
<${Button} icon=${Icons.bolt} onclick=${onClick} title=${connected ? 'Disconnect' : 'Connect'} />
|
||||
<${Button} extraClass="w-32" onclick=${onClick} title=${connected ? 'Disconnect' : 'Connect'} />
|
||||
<//>
|
||||
<//>
|
||||
<//>`;
|
||||
|
|
@ -174,252 +109,173 @@ function Header({topic, setTopic, url, setUrl, connected}) {
|
|||
|
||||
function Sidebar({devices, onclick}) {
|
||||
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>`;
|
||||
<td class="whitespace-nowrap border-b py-2 px-4 pr-3 text-sm text-slate-700">${props.text}</td>`;
|
||||
|
||||
const Device = ({d}) => html`
|
||||
<div class="hover:bg-stone-100 cursor-pointer flex gap-3 px-4 py-2 justify-between"
|
||||
onclick=${ev => onclick(d.id)}>
|
||||
<span>${d.id}</span>
|
||||
<span class="${BadgeClass} ${d.online ? Colors.green : Colors.red}"> ${d.online ? 'online' : 'offline'} <//>
|
||||
<span class="${BadgeClass} ${d.online ? Colors.green : Colors.red} w-16 text-center"> ${d.online ? 'online' : 'offline'} <//>
|
||||
<//>`;
|
||||
|
||||
return html`
|
||||
<div class="overflow-auto divide-y">
|
||||
<div class="${TitleClass} flex items-center px-4 py-2 bg-stone-100">
|
||||
<div class="${TitleClass} flex items-center px-4 py-2 text-slate-400">
|
||||
Device List
|
||||
<//>
|
||||
${(devices ? devices : []).map(d => h(Device, {d}))}
|
||||
<//>`;
|
||||
};
|
||||
|
||||
function FirmwareStatus({title, info, children}) {
|
||||
const state =
|
||||
['UNAVAILABLE', 'FIRST_BOOT', 'NOT_COMMITTED',
|
||||
'COMMITTED'][(info.status || 0) % 4];
|
||||
const valid = info.status > 0;
|
||||
export function File({accept, fn, ...rest}) {
|
||||
const btn = useRef(null);
|
||||
const input = useRef(null);
|
||||
|
||||
const oncancel = function(ev) { input.resolve(); input.resolve = null; };
|
||||
const onchange = function(ev) {
|
||||
if (!ev.target.files[0]) { input.resolve(); input.resolve = null; return; }
|
||||
const f = ev.target.files[0];
|
||||
const reader = new FileReader();
|
||||
reader.readAsArrayBuffer(f);
|
||||
reader.onload = function() {
|
||||
fn && fn(reader.result, f.type).then(() => {
|
||||
input.resolve();
|
||||
}).finally(() => {
|
||||
input.resolve = null;
|
||||
});
|
||||
};
|
||||
btn.current && btn.current.base.click();
|
||||
ev.target.value = '';
|
||||
ev.preventDefault();
|
||||
};
|
||||
const onclick = function() {
|
||||
if (!input.fn) input.current.click();
|
||||
return new Promise(resolve => { input.resolve = resolve; });
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="bg-white divide-y border rounded">
|
||||
<div class="${TitleClass} bg-white flex items-center 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}
|
||||
<//>
|
||||
<//>`;
|
||||
<span>
|
||||
<input style="display:none;" type="file" ref=${input} oncancel=${oncancel} onchange=${onchange} accept=${accept} />
|
||||
<${Button} onclick=${onclick} ref=${btn} ...${rest} />
|
||||
<//>`;
|
||||
};
|
||||
|
||||
function FirmwareUpdatePanel({no, name, current, previous, updated_at, refresh}) {
|
||||
const [color, setColor] = useState(Colors.green);
|
||||
const [otaStatus, setOtaStatus] = useState('up-to-date');
|
||||
const Descr = ({label, value}) => html`
|
||||
<div class="flex items-center justify-between">
|
||||
<span class=${LabelClass}>${label}<//>
|
||||
<span class="text-sm">${value}<//>
|
||||
<//>`;
|
||||
const onprogress = (len, total) => {
|
||||
setColor(Colors.info);
|
||||
setOtaStatus(`uploading, ${parseInt(len * 100 /total)}% done...`);
|
||||
}
|
||||
const setparam = (key, val) => fetch('api/settings/set', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({key, val}),
|
||||
}).then(handleFetchError);
|
||||
const onupload = function(ok, fileName, fileLength) {
|
||||
if (ok) {
|
||||
return setparam(`ecu.${no}.new`, fileName).then(refresh);
|
||||
} else {
|
||||
setColor(Colors.red), setOtaStatus('upload failed');
|
||||
}
|
||||
};
|
||||
const onfileselect = function(fileName, fileLength) {
|
||||
const ok = fileName.startsWith(name);
|
||||
if (!ok) alert(`Firmware file name must be ${name}.VERSION.BIN_OR_HEX`);
|
||||
return ok;
|
||||
}
|
||||
const update = rollback => new Promise(function(resolve, reject) {
|
||||
const opts = {method: 'POST', body: JSON.stringify({no, rollback})};
|
||||
const fail = () => (reject(), setOtaStatus('update error'), setColor(Colors.red));
|
||||
const finish = () => fetch('api/ecu/update/end', opts)
|
||||
.then(r => r.ok ? resolve() : reject())
|
||||
.then(refresh);
|
||||
const write = () => fetch('api/ecu/update/write', opts).then(r => {
|
||||
if (!r.ok) fail();
|
||||
//if (r.ok) r.json().then(r => console.log('Goo', r));
|
||||
if (r.ok) r.json().then(r => r == 1 ? write() : finish());
|
||||
});
|
||||
fetch('api/ecu/update/begin', opts).then(r => r.ok ? write() : fail());
|
||||
function arrayBufferToBase64Async(buffer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blob = new Blob([buffer]);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result; // reader.result is like "data:application/octet-stream;base64,AAAA..."
|
||||
resolve(dataUrl.split(",", 2)[1]);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
const onflash = ev => update(false);
|
||||
const onrollback = ev => update(true);
|
||||
}
|
||||
|
||||
useEffect(function() {
|
||||
//setOtaStatus(anew ? 'update pending' : 'up-to-date');
|
||||
//setColor(anew ? Colors.yellow : Colors.green);
|
||||
}, []);
|
||||
function FirmwareUpdatePanel({device, rpc, connected}) {
|
||||
|
||||
const fn = function (fileData) {
|
||||
// Split file in chunks
|
||||
let chunkSize = 4096, offset = 0;
|
||||
const chunks = Array.from({ length: Math.ceil(fileData.byteLength / chunkSize) }, (_, i) =>
|
||||
fileData.slice(i * chunkSize, i * chunkSize + chunkSize)
|
||||
);
|
||||
function next(resolve, reject) {
|
||||
const chunk = chunks.shift();
|
||||
if (chunks.length == 0) resolve();
|
||||
return arrayBufferToBase64Async(chunk)
|
||||
.then(encoded => rpc('ota.upload', {
|
||||
offset: offset,
|
||||
total: fileData.byteLength,
|
||||
chunk: encoded,
|
||||
}))
|
||||
.then(response => {
|
||||
if (response.result == 'ok') {
|
||||
next(resolve, reject);
|
||||
offset += chunkSize;
|
||||
} else {
|
||||
reject();
|
||||
}
|
||||
});
|
||||
};
|
||||
return new Promise((resolve, reject) => next(resolve, reject));
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="divide-y border rounded bg-white">
|
||||
<div class="divide-y border rounded bg-white w-72">
|
||||
<div class="px-4 py-2 flex justify-between items-center rounded">
|
||||
<span class="${TitleClass}">Firmware Update<//>
|
||||
<div class="flex gap-2"><span class=${LabelClass}>Status:</span><span class="${BadgeClass} ${color}">${otaStatus}<//><//>
|
||||
<//>
|
||||
<div class="p-4 flex flex-col gap-1">
|
||||
<${Descr} label="Current firmware:" value=${current || 'n/a'} />
|
||||
<${Descr} label="Updated at:" value=${updated_at || 'n/a'} />
|
||||
<div class="flex items-center justify-between whitespace-nowrap">
|
||||
<span class=${LabelClass}>Previous firmware:<//>
|
||||
<span class="flex gap-1 items-center">
|
||||
<span class="text-sm">${previous}<//>
|
||||
<${Button} title="rollback" icon=${Icons.bolt} onclick=${onrollback}
|
||||
extraClass="w-24" disabled=${!previous} />
|
||||
<//>
|
||||
<div class="flex justify-between align-center gap-2">
|
||||
<div class="${LabelClass}">Current firmware version<//>
|
||||
<div class="text-bold">${device.firmware_version || '??'}<//>
|
||||
<//>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class=${LabelClass}>New firmware:<//>
|
||||
<span class="flex gap-1 align-center justify-between">
|
||||
<${UploadFileButton} title="upload" url="api/fw/upload"
|
||||
onfileselect=${onfileselect}
|
||||
onprogress=${onprogress} oncomplete=${onupload} />
|
||||
<${Button} title="flash" icon=${Icons.bolt} onclick=${onflash}
|
||||
extraClass="w-24" disabled=${!previous} />
|
||||
<//>
|
||||
|
||||
<div class="flex justify-between align-center gap-2">
|
||||
<div class="${LabelClass}">Upload new firmware<//>
|
||||
<${File} title="..." disabled=${!device.online || !connected} fn=${fn} />
|
||||
<//>
|
||||
<//>
|
||||
<//>`;
|
||||
};
|
||||
|
||||
function DeviceSettingsPanel({device, publishFn, connected}) {
|
||||
const [config, setConfig] = useState({});
|
||||
const logOptions = [[0, 'Disable'], [1, 'Error'], [2, 'Info'], [3, 'Debug']];
|
||||
function LedControlPanel({device, rpc, connected}) {
|
||||
const ontoggle = () => rpc('state.set', {led_status: !device.led_status});
|
||||
|
||||
const onSave = ev => publishFn('config.set', config).then(r => {
|
||||
//setDeviceConfig(device.id, config);
|
||||
}).catch(e => alert('Failure!'));
|
||||
return html`
|
||||
<div class="divide-y border rounded bg-white xbg-slate-100 my-y w-64">
|
||||
<div class="px-4 py-2 flex justify-between items-center rounded ${TitleClass}">
|
||||
<div class="flex gap-2 items-center">LED Control Panel<//>
|
||||
<//>
|
||||
<div class="p-4 flex justify-between align-center gap-2">
|
||||
<div class="${LabelClass}">Toggle LED<//>
|
||||
<${Toggle} onclick=${ontoggle} disabled=${!device.online || !connected} value=${device.led_status} />
|
||||
<//>
|
||||
<//> `;
|
||||
};
|
||||
|
||||
function DeviceDashboard({device, rpc, connected}) {
|
||||
// To delete device, set an empty retained message
|
||||
const onforget = function(ev) {
|
||||
MqttClient.publish(device.topic, '', {retain: true});
|
||||
location.reload();
|
||||
};
|
||||
const onloglevelchange = ev => setConfig(x => Object.assign({}, x, {log_level: parseInt(ev.target.value)}));
|
||||
const onpinschange = ev => {
|
||||
const pin_map = ev.target.value.split(/\s*,\s*/).map(x => parseInt(x));
|
||||
setConfig(x => Object.assign({}, x, {
|
||||
pin_map, // Send an updated pin map
|
||||
pin_count: pin_map.length, // And pin count
|
||||
pin_state:[] // Remove pin_state from the request, as pin_map change can invalidate it
|
||||
}));
|
||||
}
|
||||
//console.log('sp', device, config);
|
||||
|
||||
useEffect(() => setConfig(device.config) , [device]);
|
||||
|
||||
return html`
|
||||
<div class="divide-y border rounded bg-white">
|
||||
<div class="px-4 py-2 flex justify-between items-center rounded ${TitleClass}">
|
||||
<div class="flex gap-2 items-center"> Settings <//>
|
||||
<//>
|
||||
<div class="p-4 gap-3">
|
||||
<div class="flex justify-between my-1">
|
||||
<span class=${LabelClass}>Status<//>
|
||||
<span class="${BadgeClass} ${device.online ? Colors.green : Colors.red}"> ${connected ? 'online' : 'offline'} <//>
|
||||
<//>
|
||||
<div class="flex justify-between my-1">
|
||||
<span class=${LabelClass}>Device ID<//>
|
||||
<span class="${LabelClass}">${device.id}<//>
|
||||
<//>
|
||||
<div class="grid grid-cols-2 my-1">
|
||||
<span class="${LabelClass}">Log Level<//>
|
||||
<select onchange=${onloglevelchange} class="w-full rounded font-normal border py-0.5 px-1 text-gray-600 focus:outline-none text-sm disabled:cursor-not-allowed" disabled=${!connected}>
|
||||
${logOptions.map(v => html`<option value=${v[0]} selected=${v[0] == config.log_level}>${v[1]}<//>`) }
|
||||
<//>
|
||||
<//>
|
||||
<div class="grid grid-cols-2 my-1">
|
||||
<span class="${LabelClass}">Show Pins<//>
|
||||
<div class="flex w-full items-center">
|
||||
<input disabled=${!connected} value=${config.pin_map}
|
||||
onchange=${onpinschange} class=${InputClass}
|
||||
placeholder="comma-separated pin numbers" />
|
||||
<//>
|
||||
<//>
|
||||
|
||||
<div class="mt-3 flex justify-between gap-2">
|
||||
<${Button} title="Forget this device"
|
||||
disabled=${device.online || !connected} icon=${Icons.delete}
|
||||
onclick=${onforget}/>
|
||||
<${Button} icon=${Icons.save} onclick=${onSave} title="Save Settings" disabled=${!device.online} />
|
||||
<//>
|
||||
<//>
|
||||
<//> `;
|
||||
};
|
||||
|
||||
function DeviceControlPanel({device, config, setConfig, publishFn, connected}) {
|
||||
const onclick = function(i) { // Send request to toggle pin i
|
||||
let configCopy = Object.assign({}, config);
|
||||
configCopy.pin_state[i] = !!configCopy.pin_state[i] ? 0 : 1;
|
||||
return publishFn('config.set', configCopy).catch(e => alert('Failure!'));
|
||||
};
|
||||
|
||||
const Pin = i => html`
|
||||
<div class="inline-block my-1 bg-gray-100 rounded px-1 py-1">
|
||||
<div class="flex align-center justify-between">
|
||||
<span class="${LabelClass}">Pin ${config.pin_map[i]}<//>
|
||||
<${Toggle} onclick=${ev => onclick(i)}
|
||||
disabled=${!device.online || !connected}
|
||||
value=${config.pin_state[i]} />
|
||||
<//>
|
||||
<//>`;
|
||||
|
||||
return html`
|
||||
<div class="divide-y border rounded bg-white xbg-slate-100 my-y">
|
||||
<div class="px-4 py-2 flex justify-between items-center rounded ${TitleClass}">
|
||||
<div class="flex gap-2 items-center">Pin Control Panel<//>
|
||||
<//>
|
||||
<div class="p-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
${(config.pin_map || []).map((_, i) => Pin(i))}
|
||||
<div><//>
|
||||
<//>
|
||||
<//> `;
|
||||
};
|
||||
|
||||
function DeviceDashboard({device, setDeviceConfig, publishFn, connected}) {
|
||||
const cfg = device && device.config ? device.config : DefaultDeviceConfig;
|
||||
const [localConfig, setLocalConfig] = useState(cfg);
|
||||
useEffect(() => setLocalConfig(cfg), [device]);
|
||||
|
||||
if (!device || !localConfig) {
|
||||
if (!device) {
|
||||
return html`
|
||||
<div class="flex grow text-gray-400 justify-center items-center text-xl">
|
||||
<div class="flex grow text-gray-400 justify-center items-center text-xl h-full">
|
||||
No device selected. Click on a device on a sidebar
|
||||
<//>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="p-3 grid grid-cols-3 gap-2">
|
||||
<${DeviceSettingsPanel} device=${device} config=${localConfig} setConfig=${setLocalConfig} publishFn=${publishFn} connected=${connected} />
|
||||
<${DeviceControlPanel} device=${device} config=${localConfig} setConfig=${setLocalConfig} publishFn=${publishFn} connected=${connected} />
|
||||
<${FirmwareUpdatePanel} deviceID=${device.id} publishFn=${publishFn}
|
||||
disabled=${!device.online}
|
||||
info=${[localConfig.crnt_fw, localConfig.prev_fw]} />
|
||||
</div>`;
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<div class="text-xl flex items-center gap-3">
|
||||
<span class="text-slate-400">Device ${device.id}<//>
|
||||
<${Button} title="Forget this device" onclick=${onforget}/>
|
||||
<//>
|
||||
<div class="flex gap-2">
|
||||
<${LedControlPanel} device=${device} rpc=${rpc} connected=${connected} />
|
||||
<${FirmwareUpdatePanel} device=${device} rpc=${rpc} connected=${connected} />
|
||||
<//>
|
||||
<//>`;
|
||||
}
|
||||
|
||||
const App = function() {
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [currentDevID, setCurrentDevID] = useState(localStorage.getItem('currentDevID') || '');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [url, setUrl] = useState(localStorage.getItem('url') || DefaultUrl);
|
||||
const [topic, setTopic] = useState(localStorage.getItem('topic') || DefaultTopic);
|
||||
const [error, setError] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const sigDevices = useSignal([]);
|
||||
const sigCurrentDevID = useSignal(localStorage.getItem('currentDevID') || '');
|
||||
const sigUrl = useSignal(localStorage.getItem('url') || DefaultUrl);
|
||||
const sigTopic = useSignal(localStorage.getItem('topic') || DefaultTopic);
|
||||
const sigError = useSignal(null);
|
||||
const sigLoading = useSignal(true);
|
||||
const sigConnected = useSignal(false);
|
||||
const responseHandlers = useRef({});
|
||||
|
||||
const getDeviceByID = (deviceID) => sigDevices.value.find(d => d.id === deviceID);
|
||||
|
||||
|
||||
function addResponseHandler(correlationId, handler) {
|
||||
responseHandlers[correlationId] = handler;
|
||||
}
|
||||
|
|
@ -428,19 +284,19 @@ const App = function() {
|
|||
delete responseHandlers[correlationId];
|
||||
}
|
||||
|
||||
const onRefresh = ev => window.location.reload();
|
||||
const onRefresh = () => window.location.reload();
|
||||
|
||||
const initConn = () => {
|
||||
MqttClient = mqtt.connect(url, {connectTimeout: 5000, reconnectPeriod: 0});
|
||||
MqttClient = mqtt.connect(sigUrl.value, {connectTimeout: 5000, reconnectPeriod: 0});
|
||||
|
||||
MqttClient.on('connect', () => {
|
||||
//console.log('Connected to the broker');
|
||||
setLoading(false);
|
||||
setError(null); // Reset error state upon successful connection
|
||||
setConnected(true);
|
||||
sigLoading.value = false;
|
||||
sigError.value = null; // Reset error state upon successful connection
|
||||
sigConnected.value = true;
|
||||
|
||||
const statusTopic = topic + '/+/status'
|
||||
const txTopic = topic + '/+/tx'
|
||||
const statusTopic = sigTopic.value + '/+/status'
|
||||
const txTopic = sigTopic.value + '/+/tx'
|
||||
|
||||
const subscribe = (topic) => {
|
||||
MqttClient.subscribe(topic, (err) => {
|
||||
|
|
@ -458,7 +314,7 @@ const App = function() {
|
|||
});
|
||||
|
||||
MqttClient.on('message', (topic, message) => {
|
||||
// console.log(`Received message from ${topic}: ${message.toString()}`);
|
||||
console.log(`Received message from ${topic}: ${message.toString()}`);
|
||||
if (message.length == 0) return;
|
||||
let response;
|
||||
try {
|
||||
|
|
@ -469,19 +325,18 @@ const App = function() {
|
|||
}
|
||||
|
||||
if (topic.endsWith('/status')) {
|
||||
let device = {topic: topic, id: topic.split('/')[1], config: DefaultDeviceConfig};
|
||||
const params = response.params;
|
||||
if (!params) {
|
||||
if (!response.params) {
|
||||
console.error('Invalid response');
|
||||
return;
|
||||
}
|
||||
device.online = params.status === 'online';
|
||||
//setConnected(device.online);
|
||||
if (device.online) {
|
||||
device.config = params;
|
||||
if (!device.config.pins) device.config.pins = [];
|
||||
}
|
||||
setDevice(device)
|
||||
let device = Object.assign(response.params, {topic: topic, id: topic.split('/')[1]});
|
||||
device.online = response.params.status === 'online';
|
||||
const devices = sigDevices.value.filter(d => d.id !== device.id);
|
||||
devices.push(device);
|
||||
devices.sort((a, b) => a.online && !b.online ? -1
|
||||
:!a.online && b.online ? 1
|
||||
: a.id < b.id ? -1 : 1);
|
||||
sigDevices.value = devices;
|
||||
} else if (topic.endsWith('/tx')) {
|
||||
if (!response.id) {
|
||||
console.error('Invalid response');
|
||||
|
|
@ -497,14 +352,14 @@ const App = function() {
|
|||
|
||||
MqttClient.on('error', (err) => {
|
||||
console.error('Connection error:', err);
|
||||
setError('Connection cannot be established.');
|
||||
sigError.value = 'Connection cannot be established.';
|
||||
});
|
||||
|
||||
MqttClient.on('close', () => {
|
||||
if (!connected) {
|
||||
if (!sigConnected.value) {
|
||||
console.error('Failed to connect to the broker.');
|
||||
setError('Connection cannot be established.');
|
||||
setLoading(false);
|
||||
sigError.value = 'Connection cannot be established.';
|
||||
sigLoading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -527,60 +382,30 @@ const App = function() {
|
|||
resolve(messageData);
|
||||
});
|
||||
|
||||
if (currentDevID) {
|
||||
const rxTopic = topic + `/${currentDevID}/rx`;
|
||||
if (sigCurrentDevID.value) {
|
||||
const rxTopic = sigTopic.value + `/${sigCurrentDevID.value}/rx`;
|
||||
const rpcPayload = {method: methodName, id: randomID};
|
||||
if (parameters) {
|
||||
rpcPayload.params = parameters;
|
||||
}
|
||||
console.log(`Sending message to ${rxTopic}: ${JSON.stringify(rpcPayload)}`);
|
||||
MqttClient.publish(rxTopic, JSON.stringify(rpcPayload));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getDeviceByID = (deviceID) => 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);
|
||||
sigCurrentDevID.value = device.id;
|
||||
localStorage.setItem('currentDevID', device.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
if (sigError.value) {
|
||||
return html`
|
||||
<div class="min-h-screen bg-slate-100 flex flex-col">
|
||||
<${Header}/>
|
||||
<${Header} sigTopic=${sigTopic} sigUrl=${sigUrl} />
|
||||
<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>
|
||||
|
|
@ -588,8 +413,7 @@ const App = function() {
|
|||
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" />
|
||||
<${Button} title="Retry" onclick=${onRefresh} class="absolute top-4 right-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -598,19 +422,17 @@ const App = function() {
|
|||
|
||||
return html`
|
||||
<div class="h-full flex flex-col">
|
||||
<${Header}
|
||||
topic=${topic} setTopic=${setTopic} url=${url}
|
||||
setUrl=${setUrl} connected=${connected}
|
||||
/>
|
||||
<${Header} sigTopic=${sigTopic} sigUrl=${sigUrl} connected=${sigConnected.value} />
|
||||
<div class="flex grow overflow-auto">
|
||||
<div class="w-60 overflow-auto">
|
||||
<${Sidebar} devices=${devices} onclick=${onDeviceClick} />
|
||||
<div class="w-64 overflow-auto">
|
||||
<${Sidebar} devices=${sigDevices.value} onclick=${onDeviceClick} />
|
||||
<//>
|
||||
<div class="grow bg-gray-200">
|
||||
<${DeviceDashboard}
|
||||
device=${getDeviceByID(currentDevID)} connected=${connected}
|
||||
setDeviceConfig=${setDeviceConfig} publishFn=${handlePublish} />
|
||||
device=${getDeviceByID(sigCurrentDevID.value)} connected=${sigConnected.value}
|
||||
rpc=${handlePublish} />
|
||||
<//>
|
||||
<${Help} />
|
||||
<//>
|
||||
<//>`;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
#include "hal.h"
|
||||
|
||||
// Handle interrupts, like Ctrl-C
|
||||
char *g_firmware_version = "1.0.0";
|
||||
static int s_signo;
|
||||
static void signal_handler(int signo) {
|
||||
s_signo = signo;
|
||||
|
|
@ -22,13 +23,15 @@ int main(int argc, char *argv[]) {
|
|||
g_device_id = strdup(argv[++i]);
|
||||
} else if (strcmp(argv[i], "-t") == 0 && argv[i + 1] != NULL) {
|
||||
g_root_topic = argv[++i];
|
||||
} else if (strcmp(argv[i], "-f") == 0 && argv[i + 1] != NULL) {
|
||||
g_firmware_version = argv[++i];
|
||||
} else if (strcmp(argv[i], "-v") == 0 && argv[i + 1] != NULL) {
|
||||
mg_log_set(atoi(argv[++i]));
|
||||
} else {
|
||||
MG_ERROR(("Unknown option: %s. Usage:", argv[i]));
|
||||
MG_ERROR(
|
||||
("%s [-u mqtt://SERVER:PORT] [-i DEVICE_ID] [-t TOPIC_NAME] [-v "
|
||||
"DEBUG_LEVEL]",
|
||||
"DEBUG_LEVEL] [-f FIRMWARE_VERSION]",
|
||||
argv[0], argv[i]));
|
||||
return 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
// All rights reserved
|
||||
|
||||
#include "net.h"
|
||||
#include <string.h>
|
||||
#include "mongoose.h"
|
||||
|
||||
char *g_mqtt_server_url = MQTT_SERVER_URL;
|
||||
char *g_root_topic = MQTT_ROOT_TOPIC;
|
||||
|
|
@ -11,15 +13,12 @@ static uint8_t s_qos = 1; // MQTT QoS
|
|||
static struct mg_connection *s_conn; // MQTT Client connection
|
||||
static struct mg_rpc *s_rpc = NULL; // List of registered RPC methods
|
||||
|
||||
#define MAX_PINS 20
|
||||
struct device_config {
|
||||
int log_level; // Device logging level, 0-4
|
||||
int pin_count; // Number of pins to handle
|
||||
uint16_t pin_map[MAX_PINS]; // Pins to handle
|
||||
bool pin_state[MAX_PINS]; // State of the GPIO pins
|
||||
struct device_state {
|
||||
bool led_status;
|
||||
char firmware_version[20];
|
||||
};
|
||||
|
||||
static struct device_config s_device_config;
|
||||
static struct device_state s_device_state;
|
||||
|
||||
// Device ID generation function. Create an ID that is unique
|
||||
// for a given device, and does not change between device restarts.
|
||||
|
|
@ -53,40 +52,19 @@ static void set_device_id(void) {
|
|||
g_device_id = strdup(buf);
|
||||
}
|
||||
|
||||
static size_t print_shorts(void (*out)(char, void *), void *ptr, va_list *ap) {
|
||||
uint16_t *array = va_arg(*ap, uint16_t *);
|
||||
int i, len = 0, num_elems = va_arg(*ap, int);
|
||||
for (i = 0; i < num_elems; i++) {
|
||||
len += mg_xprintf(out, ptr, "%s%hu", i ? "," : "", array[i]);
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
static size_t print_bools(void (*out)(char, void *), void *ptr, va_list *ap) {
|
||||
bool *array = va_arg(*ap, bool *);
|
||||
int i, len = 0, num_elems = va_arg(*ap, int);
|
||||
for (i = 0; i < num_elems; i++) {
|
||||
len += mg_xprintf(out, ptr, "%s%d", i ? "," : "", array[i]);
|
||||
}
|
||||
return len;
|
||||
}
|
||||
|
||||
static void publish_status(struct mg_connection *c) {
|
||||
char topic[100];
|
||||
struct mg_mqtt_opts pub_opts;
|
||||
struct mg_iobuf io = {0, 0, 0, 512};
|
||||
|
||||
// Print JSON notification into the io buffer
|
||||
mg_xprintf(mg_pfn_iobuf, &io,
|
||||
"{%m:%m,%m:{%m:%m,%m:%d,%m:%d,%m:[%M],%m:[%M]}}", //
|
||||
MG_ESC("method"), MG_ESC("status.notify"), MG_ESC("params"), //
|
||||
MG_ESC("status"), MG_ESC("online"), //
|
||||
MG_ESC(("log_level")), s_device_config.log_level, //
|
||||
MG_ESC(("pin_count")), s_device_config.pin_count, //
|
||||
MG_ESC(("pin_map")), print_shorts, s_device_config.pin_map,
|
||||
s_device_config.pin_count, //
|
||||
MG_ESC(("pin_state")), print_bools, s_device_config.pin_state,
|
||||
s_device_config.pin_count);
|
||||
mg_xprintf(
|
||||
mg_pfn_iobuf, &io,
|
||||
"{%m:%m,%m:{%m:%m,%m:%s,%m:%m}}", //
|
||||
MG_ESC("method"), MG_ESC("status.notify"), MG_ESC("params"), //
|
||||
MG_ESC("status"), MG_ESC("online"), //
|
||||
MG_ESC("led_status"), s_device_state.led_status ? "true" : "false", //
|
||||
MG_ESC("firmware_version"), MG_ESC(s_device_state.firmware_version));
|
||||
|
||||
memset(&pub_opts, 0, sizeof(pub_opts));
|
||||
mg_snprintf(topic, sizeof(topic), "%s/%s/status", g_root_topic, g_device_id);
|
||||
|
|
@ -121,32 +99,9 @@ static void subscribe(struct mg_connection *c) {
|
|||
mg_free(rx_topic);
|
||||
}
|
||||
|
||||
static void rpc_config_set(struct mg_rpc_req *r) {
|
||||
struct device_config dc = s_device_config;
|
||||
dc.pin_count = (int) mg_json_get_long(r->frame, "$.params.pin_count", -1);
|
||||
dc.log_level = (int) mg_json_get_long(r->frame, "$.params.log_level", -1);
|
||||
|
||||
if (dc.log_level < 0 || dc.log_level > MG_LL_VERBOSE) {
|
||||
mg_rpc_err(r, -32602, "Log level must be from 0 to 4");
|
||||
} else if (dc.pin_count <= 0 || dc.pin_count > MAX_PINS) {
|
||||
mg_rpc_err(r, -32602, "Pin count must be from 1 to %d", MAX_PINS);
|
||||
} else {
|
||||
int i, val;
|
||||
for (i = 0; i < dc.pin_count; i++) {
|
||||
char path[50];
|
||||
mg_snprintf(path, sizeof(path), "$.params.pin_map[%d]", i);
|
||||
dc.pin_map[i] = (uint16_t) mg_json_get_long(r->frame, path, 0);
|
||||
mg_snprintf(path, sizeof(path), "$.params.pin_state[%d]", i);
|
||||
if ((val = (int) mg_json_get_long(r->frame, path, -1)) >= 0) {
|
||||
gpio_write(dc.pin_map[i], val);
|
||||
}
|
||||
dc.pin_state[i] = gpio_read(dc.pin_map[i]);
|
||||
// MG_INFO(("%d %d %d", i, dc.pin_map[i], dc.pin_state[i]));
|
||||
}
|
||||
mg_log_set(dc.log_level);
|
||||
s_device_config = dc;
|
||||
mg_rpc_ok(r, "true");
|
||||
}
|
||||
static void rpc_state_set(struct mg_rpc_req *r) {
|
||||
mg_json_get_bool(r->frame, "$.params.led_status", &s_device_state.led_status);
|
||||
mg_rpc_ok(r, "true");
|
||||
}
|
||||
|
||||
static void rpc_ota_upload(struct mg_rpc_req *r) {
|
||||
|
|
@ -244,29 +199,24 @@ static void timer_sntp(void *param) { // SNTP timer function. Sync up time
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
void web_init(struct mg_mgr *mgr) {
|
||||
int i, ping_interval_ms = MQTT_KEEPALIVE_SEC * 1000 - 500;
|
||||
int ping_interval_ms = MQTT_KEEPALIVE_SEC * 1000 - 500;
|
||||
int flags = MG_TIMER_REPEAT;
|
||||
set_device_id();
|
||||
s_device_config.log_level = (int) mg_log_level;
|
||||
s_device_config.pin_count = 5;
|
||||
s_device_config.pin_map[0] = 10;
|
||||
s_device_config.pin_map[1] = 11;
|
||||
s_device_config.pin_map[2] = 12;
|
||||
s_device_config.pin_map[3] = 13;
|
||||
s_device_config.pin_map[4] = 25;
|
||||
for (i = 0; i < s_device_config.pin_count; i++) {
|
||||
s_device_config.pin_state[i] = gpio_read(s_device_config.pin_map[i]);
|
||||
}
|
||||
|
||||
// Initialize device state
|
||||
s_device_state.led_status = false;
|
||||
mg_snprintf(s_device_state.firmware_version,
|
||||
sizeof(s_device_state.firmware_version), "%s",
|
||||
g_firmware_version);
|
||||
|
||||
// Configure JSON-RPC functions we're going to handle
|
||||
mg_rpc_add(&s_rpc, mg_str("config.set"), rpc_config_set, NULL);
|
||||
mg_rpc_add(&s_rpc, mg_str("state.set"), rpc_state_set, NULL);
|
||||
mg_rpc_add(&s_rpc, mg_str("ota.upload"), rpc_ota_upload, NULL);
|
||||
|
||||
mg_timer_add(mgr, 3000, MG_TIMER_REPEAT | MG_TIMER_RUN_NOW, timer_reconnect,
|
||||
mgr);
|
||||
mg_timer_add(mgr, ping_interval_ms, MG_TIMER_REPEAT, timer_ping, mgr);
|
||||
mg_timer_add(mgr, 2000, MG_TIMER_REPEAT, timer_sntp, mgr);
|
||||
|
||||
mg_timer_add(mgr, 3000, flags | MG_TIMER_RUN_NOW, timer_reconnect, mgr);
|
||||
mg_timer_add(mgr, ping_interval_ms, flags, timer_ping, mgr);
|
||||
mg_timer_add(mgr, 2000, flags, timer_sntp, mgr);
|
||||
}
|
||||
|
||||
void web_free(void) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ extern "C" {
|
|||
extern char *g_mqtt_server_url;
|
||||
extern char *g_device_id;
|
||||
extern char *g_root_topic;
|
||||
extern char *g_firmware_version;
|
||||
|
||||
void web_init(struct mg_mgr *mgr);
|
||||
void web_free(void);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue