import { ADDR, FUNC, ARG, Message } from './protocol.js'; import { hex, concat, hash } from './utils.js'; import * as plot from './plot.js'; import * as PROTOCOL from './protocol.js'; import * as UTILS from './utils.js'; for (const key in PROTOCOL) { window[key] = PROTOCOL[key]; } for (const key in UTILS) { window[key] = UTILS[key]; } // send a message (uint16 data) window.send16 = (func, ...data) => { const msg = Message.from16(ADDR.OVEN, func, data); console.info(msg.toString(), msg); return PORT.write(msg.buf); }; // send a message (uint8 data) window.send8 = (func, ...data) => { const msg = Message.from8(ADDR.OVEN, func, data); console.info(msg.toString(), msg); return PORT.write(msg.buf); }; const samplePlot = (data, interval=4) => { if (data.length < 2) return data; data = data.slice(); const result = []; let i = 0; let prev; while (data.length) { while (data.length && data[0][0] <= i) { prev = data.shift(); } const next = data[0]; if (!next) { result.push([ i, prev[1] ]); break; } const dtemp = next[1] - prev[1]; const dtime = next[0] - prev[0]; const t = (i - prev[0]) / dtime; result.push([ i, Math.round(prev[1] + t * dtemp) ]); i += interval; } return result; }; const unsamplePlot = (data, tolerance_interval=4) => { if (data.length < 2) return data; const result = []; result.push(data[0]); const tolerance = 1.1 / tolerance_interval; for (let i = 1; i < data.length - 1; i++) { const prev = data[i - 1]; const curr = data[i]; const next = data[i + 1]; const left = (curr[1] - prev[1]) / (curr[0] - prev[0]); const right = (next[1] - curr[1]) / (next[0] - curr[0]); if (Math.abs(left - right) < tolerance) continue; result.push(curr); } result.push(data[data.length - 1]); return result; }; let PORT = null; // { write(buf), disconnect() } | null let STATUS = null; // null | 'connecting' | 'idle' | 'manual' | 'running' let START_TIME = null; let END_TIME = null; let TIMEOUT; plot.addPlot('paste', { color: 'slategray', data: [[0, 24]], interactive: true, }); plot.addPlot('profile', { color: 'teal', data: [[0, 24]], interactive: true, }); plot.addPlot('temp1', { color: 'plum' }); plot.addPlot('temp2', { color: 'salmon' }); plot.addPlot('temp3', { color: 'sienna' }); plot.addPlot('tempavg', { color: 'rebeccapurple' }); const elements = [ 'connect', 'status', 'temps_chamber', 'temps_controller', 'profile', 'start', 'read', 'write', 'heat', 'heat_mode', 'cool', 'cool_mode', 'profile_load', 'profile_save', 'profile_delete', 'profile_show', 'profile_edit', 'profile_clear', 'profile_stored', 'paste_load', 'paste_save', 'paste_delete', 'paste_show', 'paste_edit', 'paste_clear', 'paste_stored', ]; const el = Object.fromEntries(elements.map(id => [id, document.getElementById(id)])); const updateUI = () => { el.connect.innerText = !PORT ? 'connect' : 'disconnect'; const ready = STATUS && STATUS !== 'connecting'; el.profile.disabled = STATUS === 'running'; el.start.innerText = (STATUS !== 'running' && STATUS !== 'manual') ? 'start' : 'stop'; el.start.disabled = !ready; el.read.disabled = !ready; el.write.disabled = !ready; el.profile_show.firstElementChild.firstElementChild.href.baseVal = plot.PLOTS.profile.visible ? '#i-eye' : '#i-no-eye'; el.paste_show.firstElementChild.firstElementChild.href.baseVal = plot.PLOTS.paste.visible ? '#i-eye' : '#i-no-eye'; el.profile_edit.firstElementChild.firstElementChild.href.baseVal = plot.ACTIVE === 'profile' ? '#i-pencil' : '#i-no-pencil'; el.paste_edit.firstElementChild.firstElementChild.href.baseVal = plot.ACTIVE === 'paste' ? '#i-pencil' : '#i-no-pencil'; const end = END_TIME ?? Date.now(); const duration = START_TIME && Math.floor((end - START_TIME) / 1000); switch (STATUS) { case null: el.status.innerText = '(disconnected)'; el.temps_chamber.innerText = '-'; el.temps_controller.innerText = '-'; break; case 'connecting': el.status.innerText = '(connecting)'; break; case 'idle': el.status.innerText = 'idle'; break; case 'manual': el.status.innerText = `manual mode - ${duration}s`; break; case 'running': el.status.innerText = `profile ${+el.profile.value + 1} - ${duration}s`; break; } plot.update(); }; window.onstorage = () => { const profile = el.profile_stored.value; const paste = el.paste_stored.value; el.profile_stored.innerHTML = ''; el.paste_stored.innerHTML = ''; for (const key of Object.keys(window.localStorage)) { if (key.startsWith('profile-')) { const data = JSON.parse(window.localStorage[key]); el.profile_stored.add(new Option(data.name, key)); } else if (key.startsWith('paste-')) { const data = JSON.parse(window.localStorage[key]); el.paste_stored.add(new Option(data.name, key)); } } el.profile_stored.value = profile; el.paste_stored.value = paste; }; window.onstorage(); updateUI(); const onMessage = async (msg) => { if (msg.func === FUNC.O_TEMPS) { console.debug(msg.toString(), msg); } else { console.info(msg.toString(), msg); } if (msg.address !== ADDR.CTRL) { console.warn(`unknown address ${hex(msg.address)}`); return; } switch (msg.func) { case FUNC.O_CONNECT: { switch (msg.get16(0)) { case ARG.CONNECT_CONNECTING: console.log('oven found'); const res = Message.from16(ADDR.OVEN, FUNC.C_ACK_CONNECT, [ARG.CONNECT_CONNECTED]); await PORT.write(res.buf); break; case ARG.CONNECT_CONNECTED: console.log('connection established'); STATUS = 'idle'; break; } break; } case FUNC.O_TEMPS: { clearTimeout(TIMEOUT); TIMEOUT = setTimeout(() => { if (!PORT) return; console.error('port timed out'); PORT.disconnect(); }, 2500); const temp1 = msg.get16(0); const temp2 = msg.get16(2); const temp3 = msg.get16(4); el.temps_chamber.innerText = `${temp1}°C / ${temp2}°C`; el.temps_controller.innerText = `${temp3}°C`; if (!STATUS || STATUS === 'connecting') { STATUS = 'idle'; } else if (STATUS === 'running' || STATUS == 'manual') { const t = plot.PLOTS.temp1.data.length; plot.PLOTS.temp1.data.push([t, temp1]); plot.PLOTS.temp2.data.push([t, temp2]); plot.PLOTS.temp3.data.push([t, temp3]); plot.PLOTS.tempavg.data.push([t, (temp1 + temp2) / 2]); } break; } default: console.warn(`unknown func ${hex(msg.func)}`, msg); break; } updateUI(); } const pwms = [ [el.heat, el.heat_mode], [el.cool, el.cool_mode], ]; for (const [slider, checkbox] of pwms) { slider.onchange = async (e) => { if (!PORT) return; checkbox.checked = +slider.value; await send8(FUNC.C_PWM_HEAT, +el.heat.value, el.heat_mode.checked ? ARG.PWM_ON : ARG.PWM_OFF); await sleep(0.05); await send8(FUNC.C_PWM_COOL, +el.cool.value, el.cool_mode.checked ? ARG.PWM_ON : ARG.PWM_OFF); if (STATUS === 'idle' && el.heat_mode.checked) { STATUS = 'manual'; START_TIME = Date.now(); END_TIME = null; updateUI(); } }; checkbox.onchange = async (e) => { if (!PORT) return; if (checkbox.checked && +slider.value === 0) { slider.value = 0x64; } await send8(FUNC.C_PWM_HEAT, +el.heat.value, el.heat_mode.checked ? ARG.PWM_ON : ARG.PWM_OFF); await sleep(0.05); await send8(FUNC.C_PWM_COOL, +el.cool.value, el.cool_mode.checked ? ARG.PWM_ON : ARG.PWM_OFF); if (STATUS === 'idle' && el.heat_mode.checked) { STATUS = 'manual'; START_TIME = Date.now(); END_TIME = null; updateUI(); } }; } el.start.onclick = () => { if (STATUS === 'idle') { STATUS = el.profile.value === '' ? 'manual' : 'running'; START_TIME = Date.now(); END_TIME = null; plot.PLOTS.temp1.data.length = 0; plot.PLOTS.temp2.data.length = 0; plot.PLOTS.temp3.data.length = 0; plot.PLOTS.tempavg.data.length = 0; if (el.profile.value !== '') { const i = +el.profile.value; send8(FUNC.C_STARTSTOP, i, ARG.STARTSTOP_START); } } else if (STATUS === 'running') { STATUS = 'idle'; END_TIME = Date.now(); const i = +el.profile.value; send8(FUNC.C_STARTSTOP, i, ARG.STARTSTOP_STOP); } else if (STATUS === 'manual') { STATUS = 'idle'; END_TIME = Date.now(); } updateUI(); }; if (window.location.hash !== '#ws') { el.connect.onclick = async () => { if (!!PORT) return PORT.disconnect(); const port = await navigator.serial.requestPort(); await port.open({ baudRate: 38400 }); console.log('opened port'); let reader, writer; try { reader = port.readable.getReader(); writer = port.writable.getWriter(); const write = (buf) => writer.write(buf); const disconnect = async () => { PORT = null; STATUS = null; updateUI(); await reader.cancel().catch(() => null); await writer.close().catch(() => null); await port.close(); }; PORT = { write, disconnect }; STATUS = 'connecting'; updateUI(); let buffer = new Uint8Array(); while (true) { const { value, done } = await reader.read(); if (done) throw new Error("reader cancelled"); buffer = concat(buffer, value); do { const msg = Message.tryParse(buffer); if (!msg) break; await onMessage(msg); buffer = buffer.slice(msg.length); } while (buffer.length); } } finally { console.log('shutting down'); reader.releaseLock(); writer.releaseLock(); } }; } else { el.connect.onclick = async () => { if (!!PORT) return disconnect(); const sock = new WebSocket('ws://localhost:8001'); sock.binaryType = 'arraybuffer'; const write = (buf) => sock.send(buf); const disconnect = () => sock.close(); PORT = { write, disconnect }; STATUS = 'connecting'; updateUI(); sock.onmessage = (e) => { const msg = Message.tryParse(new Uint8Array(e.data)); if (!msg) console.error('invalid WS message', msg); onMessage(msg); }; sock.onclose = () => { console.log('shutting down'); PORT = null; STATUS = null; updateUI(); }; console.log('opened websocket'); }; } for (const p of ['profile', 'paste']) { el[p + '_show'].onclick = () => { plot.PLOTS[p].visible = !plot.PLOTS[p].visible; updateUI(); }; el[p + '_edit'].onclick = () => { plot.toggleActive(p); updateUI(); }; el[p + '_clear'].onclick = () => { plot.PLOTS[p].data = [[0, 24]]; updateUI(); }; el[p + '_load'].onclick = () => { const key = el[p + '_stored'].value; let data = window.localStorage[key]; if (!data) return; plot.PLOTS[p].data = JSON.parse(data).data; updateUI(); }; el[p + '_save'].onclick = () => { let key = el[p + '_stored'].value; let name; if (key === '') { name = window.prompt('please enter the new profile name'); if (!name) return; } else { name = JSON.parse(window.localStorage[key]).name; delete window.localStorage[key]; } const data = plot.PLOTS[p].data; if (p === 'profile') { const sampled = new Uint8Array(samplePlot(data).map(d => Math.round(d[1]))); key = `profile-${hash(sampled)}`; } else { key = `paste-${name}`; } window.localStorage[key] = JSON.stringify({ name, data }); window.onstorage(); el[p + '_stored'].value = key; }; el[p + '_delete'].onclick = () => { const key = el[p + '_stored'].value; delete window.localStorage[key]; window.onstorage(); }; }