'use strict'; import {h, html, render, useEffect, useRef, useState} from './bundle.js'; const Logo = props => html``; 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 handleFetchError = r => r.ok || alert(`Error: ${r.statusText}`); const LabelClass = 'text-sm truncate text-gray-500 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 Colors = { green: 'bg-green-100 text-green-900 ring-green-300', yellow: 'bg-yellow-100 text-yellow-900 ring-yellow-300', info: 'bg-zinc-100 text-zinc-900 ring-zinc-300', red: 'bg-red-100 text-red-900 ring-red-300', }; let MqttClient; const Icons = { logo: props => html` `, activity: props => html``, refresh: props => html``, info: props => html``, login: props => html``, logout: props => html``, menu: props => html``, upload: props => html``, monitor: props => html``, settings: props => html``, download: props => html``, file: props => html``, check: props => html``, rollback: props => html``, save: props => html``, bolt: props => html``, delete: props => html``, }; export function Button({title, onclick, disabled, extraClass, icon, ref, colors, hovercolor, disabledcolor}) { const [spin, setSpin] = useState(false); const cb = function(ev) { const res = onclick ? onclick() : null; if (res && typeof (res.catch) === 'function') { setSpin(true); res.catch(() => false).then(() => setSpin(false)); } }; if (!colors) colors = 'bg-blue-600 hover:bg-blue-500 disabled:bg-blue-400'; return html` `; }; function Header({topic, setTopic, url, setUrl, connected}) { const forbiddenChars = ['$', '*', '+', '#', '/']; const onClick = () => { const isValidTopic = val => !forbiddenChars.some(char => val.includes(char)); if (isValidTopic(topic)) { localStorage.setItem('topic', topic) setTopic(topic); window.location.reload(); } else { setSaveResult('Error: The topic cannot contain these characters: ' + forbiddenChars); } }; return html`

Device Management Dashboard

MQTT Server setUrl(ev.target.value)} class=${InputClass} />
Root Topic setTopic(ev.target.value)} class=${InputClass} /> <${Button} icon=${Icons.bolt} onclick=${onClick} title=${connected ? 'Disconnect' : 'Connect'} /> `; }; function Sidebar({devices, onclick}) { const Td = props => html` ${ props.text}`; const Device = ({d}) => html`
onclick(d.id)}> ${d.id} ${d.online ? 'online' : 'offline'} `; return html`
Devices ${(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; return html`
${title}
Status: ${state}
CRC32: ${valid ? info.crc32.toString(16) : 'n/a'}
Size: ${valid ? info.size : 'n/a'}
Flashed at: ${ valid ? new Date(info.timestamp * 1000).toLocaleString() : 'n/a'} ${children} `; }; 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`
${label} ${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()); }); 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); }, []); return html`
Firmware Update
Status:${otaStatus}
<${Descr} label="Current firmware:" value=${current || 'n/a'} /> <${Descr} label="Updated at:" value=${updated_at || 'n/a'} />
Previous firmware: ${previous} <${Button} title="rollback" icon=${Icons.bolt} onclick=${onrollback} extraClass="w-24" disabled=${!previous} />
New firmware: <${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} /> `; }; /* function xFirmwareUpdatePanel({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`
Firmware Update
boo! `; return html`
Over-the-air firmware updates
<${FirmwareStatus} title="Current firmware image" info=${info[0] ? info[0] : defaultInfo}>
<${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" />
Device control
<${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" />
<${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 DeviceSettingsPanel({device, publishFn, connected}) { const [config, setConfig] = useState(device.config); const logOptions = [[0, 'Disable'], [1, 'Error'], [2, 'Info'], [3, 'Debug']]; const onSave = ev => publishFn('config.set', config).then(r => { //setDeviceConfig(device.id, config); }).catch(e => alert('Failure!')); // 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(config); return html`
Settings
Device ${device.id} ${connected ? 'online' : 'offline'}
Log Level
<${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`
Pin ${config.pin_map[i]} <${Toggle} onclick=${ev => onclick(i)} disabled=${!device.online || !connected} value=${config.pin_state[i]} /> `; return html`
Pin Control Panel
${(config.pin_map || []).map((_, i) => Pin(i))}
`; }; 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) { return html`
No device selected. Click on a device on a sidebar `; } return html`
<${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]} />
`; } 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 responseHandlers = useRef({}); function addResponseHandler(correlationId, handler) { responseHandlers[correlationId] = handler; } function removeResponseHandler(correlationId) { delete responseHandlers[correlationId]; } const onRefresh = ev => window.location.reload(); const initConn = () => { MqttClient = mqtt.connect(url, {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); const statusTopic = topic + '/+/status' const txTopic = topic + '/+/tx' const subscribe = (topic) => { MqttClient.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) }); MqttClient.on('message', (topic, message) => { // console.log(`Received message from ${topic}: ${message.toString()}`); if (message.length == 0) return; let response; try { response = JSON.parse(message.toString()); } catch (err) { console.error(err); return; } if (topic.endsWith('/status')) { let device = {topic: topic, id: topic.split('/')[1], config: DefaultDeviceConfig}; const params = response.params; if (!params) { console.error('Invalid response'); return; } device.online = params.status === 'online' if (device.online) { device.config = params; if (!device.config.pins) device.config.pins = []; } 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); } } }); MqttClient.on('error', (err) => { console.error('Connection error:', err); setError('Connection cannot be established.'); }); MqttClient.on('close', () => { if (!connected) { 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; } 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); localStorage.setItem('currentDevID', device.id); } }; if (error) { return html`
<${Header}/>

Connection Error

Unable to connect to the MQTT broker.

<${Button} title="Retry" onclick=${onRefresh} icon=${Icons.refresh} class="absolute top-4 right-4" />
`; } return html`
<${Header} topic=${topic} setTopic=${setTopic} url=${url} setUrl=${setUrl} connected=${connected} />
<${Sidebar} devices=${devices} onclick=${onDeviceClick} />
<${DeviceDashboard} device=${getDeviceByID(currentDevID)} connected=${connected} setDeviceConfig=${setDeviceConfig} publishFn=${handlePublish} /> `; }; window.onload = () => render(h(App), document.body);