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();
};
}