mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-10-20 22:01:58 +00:00
305 lines
18 KiB
JavaScript
305 lines
18 KiB
JavaScript
(function (global) {
|
|
"use strict";
|
|
const OUTPUTS = { vibration: 1, sound: 2, led: 4, screen: 8, gpio: 16 };
|
|
const EXAMPLES = {
|
|
find: { label: "Find my node", kind: "all", id: "", when: "disconnected", vibration: "200,200,200", led: "100,100,100,700", sound: "find:d=8,o=6,b=180:c,e,g,p", screen: "on", gpio: "off", repeat: "forever", gap: "1000", stop: "connected" },
|
|
food: { label: "Your food truck order is ready", kind: "channel", id: "3", when: "any", vibration: "50,300,40,20,500", led: "100,100,100,500", sound: "order:d=8,o=5,b=180:c,e,g,4c6", screen: "on", gpio: "off", repeat: "forever", gap: "2000", stop: "button" },
|
|
vip: { label: "VIP chat", kind: "contact", id: "", when: "disconnected", vibration: "100,100,300", led: "50,300,40,20,500", sound: "vip:d=8,o=5,b=180:c,e,g,4g", screen: "on", gpio: "off", repeat: "2", gap: "500", stop: "button" }
|
|
};
|
|
function pulse(text) {
|
|
if (text === "off" || text === "inherit") return [];
|
|
if (!/^\d+(,\d+){0,11}$/.test(text)) throw new Error("Use up to 12 millisecond durations separated by commas.");
|
|
const values = text.split(",").map(Number);
|
|
if (values.some(n => n < 1 || n > 60000)) throw new Error("Each pulse duration must be 1-60000 ms.");
|
|
return values;
|
|
}
|
|
function melody(text) {
|
|
if (text === "off" || text === "inherit") return [];
|
|
if (text.length >= 64) throw new Error("The melody must be shorter than 64 ASCII characters.");
|
|
const header = text.match(/^[A-Za-z0-9_]{1,12}:d=(1|2|4|8|16|32),o=([4-7]),b=(\d+):(.+)$/);
|
|
if (!header) throw new Error("Use RTTTL: name:d=8,o=5,b=180:c,e,g,p (octaves 4-7).");
|
|
const bpm = Number(header[3]);
|
|
if (bpm < 25 || bpm > 900) throw new Error("Tempo must be 25-900 BPM.");
|
|
const semitones = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
|
let total = 0;
|
|
return header[4].split(",").map(note => {
|
|
const match = note.match(/^(1|2|4|8|16|32)?([a-gp])(#)?(\.)?([4-7])?(\.)?$/);
|
|
if (!match || (match[3] && !"acdfg".includes(match[2])) || (match[4] && match[6])) throw new Error("Invalid note. Use c-g, a, b, p for rest, # for a sharp, and one dot.");
|
|
let ms = Math.floor(Math.floor(60000 / bpm) * 4 / Number(match[1] || header[1]));
|
|
if (match[4] || match[6]) ms += Math.floor(ms / 2);
|
|
total += ms;
|
|
if (total > 60000) throw new Error("A melody must finish within 60 seconds.");
|
|
const midi = (Number(match[5] || header[2]) + 1) * 12 + (semitones[match[2]] || 0) + (match[3] ? 1 : 0);
|
|
return { ms, hz: match[2] === "p" ? 0 : 440 * Math.pow(2, (midi - 69) / 12) };
|
|
});
|
|
}
|
|
function melodyWav(text) {
|
|
const notes = melody(text), rate = 22050;
|
|
const frames = Math.round(notes.reduce((total, note) => total + note.ms, 0) * rate / 1000);
|
|
const bytes = new Uint8Array(44 + frames * 2), view = new DataView(bytes.buffer);
|
|
const label = (offset, value) => { for (let i = 0; i < value.length; i++) bytes[offset + i] = value.charCodeAt(i); };
|
|
label(0, "RIFF"); view.setUint32(4, bytes.length - 8, true); label(8, "WAVE");
|
|
label(12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
|
|
view.setUint16(22, 1, true); view.setUint32(24, rate, true); view.setUint32(28, rate * 2, true);
|
|
view.setUint16(32, 2, true); view.setUint16(34, 16, true); label(36, "data"); view.setUint32(40, frames * 2, true);
|
|
let elapsed = 0, start = 0;
|
|
for (const note of notes) {
|
|
elapsed += note.ms;
|
|
const end = Math.round(elapsed * rate / 1000), count = end - start;
|
|
if (note.hz) {
|
|
// Match the buzzer's square wave, with short fades to avoid speaker clicks.
|
|
const fade = Math.min(rate * 0.003, count / 2);
|
|
for (let i = 0; i < count; i++) {
|
|
const envelope = Math.min(1, i / fade, (count - 1 - i) / fade);
|
|
const sample = Math.round(6553 * envelope) * (i * note.hz / rate % 1 < 0.5 ? 1 : -1);
|
|
view.setInt16(44 + (start + i) * 2, sample, true);
|
|
}
|
|
}
|
|
start = end;
|
|
}
|
|
return bytes;
|
|
}
|
|
function parseOutputs(config) {
|
|
const pulses = { vibration: pulse(config.vibration), led: pulse(config.led), gpio: [] };
|
|
const notes = melody(config.sound);
|
|
if (!["inherit", "on", "off"].includes(config.screen)) throw new Error("Screen must be on, off, or inherit.");
|
|
if (config.gpio !== "off" && config.gpio !== "inherit") {
|
|
const match = config.gpio.match(/^(\d{1,2}):(.+)$/);
|
|
if (!match || Number(match[1]) > 63) throw new Error("GPIO needs pin:pattern, for example 22:50,300,50.");
|
|
pulses.gpio = pulse(match[2]);
|
|
}
|
|
return { pulses, notes };
|
|
}
|
|
function selector(config) {
|
|
let target = "all";
|
|
if (config.kind === "contact" || config.kind === "room") {
|
|
if (!/^[0-9a-fA-F]{64}$/.test(config.id)) throw new Error("Enter the contact or room server's full 64-digit public key.");
|
|
target = (config.kind === "room" ? "room:" : "contact:") + config.id.toLowerCase();
|
|
} else if (config.kind === "channel") {
|
|
if (!/^(\d{1,3}|[0-9a-fA-F]{32})$/.test(config.id)) throw new Error("Enter a channel slot, or its 32-digit key.");
|
|
target = "channel:" + config.id;
|
|
} else if (config.kind !== "all") throw new Error("Unknown target.");
|
|
if (!["any", "connected", "disconnected"].includes(config.when)) throw new Error("Unknown connection condition.");
|
|
return target + (config.when === "any" ? "" : "@" + config.when);
|
|
}
|
|
function commands(config) {
|
|
const target = selector(config), result = [];
|
|
parseOutputs(config);
|
|
if (config.repeat !== "forever" && (!/^\d+$/.test(config.repeat) || Number(config.repeat) < 1 || Number(config.repeat) > 65535)) throw new Error("Repeat must be 1-65535 or forever.");
|
|
if (!/^\d+$/.test(config.gap) || Number(config.gap) < 1 || Number(config.gap) > 60000) throw new Error("Repeat gap must be 1-60000 ms.");
|
|
if (!["button", "connected", "never"].includes(config.stop)) throw new Error("Unknown stop condition.");
|
|
for (const name of Object.keys(OUTPUTS)) {
|
|
if (!["off", "inherit"].includes(config[name])) result.push("set notify." + name + " on");
|
|
result.push("set notify." + name + " " + target + " " + config[name]);
|
|
}
|
|
for (const name of ["repeat", "gap", "stop"]) result.push("set notify." + name + " " + target + " " + config[name]);
|
|
if (config.remote && config.remote !== "inherit") {
|
|
if (!["contact", "room"].includes(config.kind) || !["on", "off"].includes(config.remote)) throw new Error("Notification-string permission needs one contact or room's full key.");
|
|
result.push("set notify.remote " + (config.kind === "room" ? "room:" : "contact:") + config.id.toLowerCase() + " " + config.remote);
|
|
}
|
|
for (const command of result) if (command.length + 4 > 175) throw new Error("This rule exceeds the device command size. Shorten the pattern or melody.");
|
|
return result;
|
|
}
|
|
function notificationText(config) {
|
|
const parts = [];
|
|
for (const name of ["vibration", "sound", "led", "screen"]) if (config[name] !== "inherit") parts.push(name + "=" + config[name]);
|
|
for (const name of ["repeat", "gap"]) parts.push(name + "=" + config[name]);
|
|
const text = "!notify " + parts.join(" ");
|
|
// Room forwarding reserves an author prefix and currently stores 150 text
|
|
// characters. Reject before a truncated post could lose a field's value.
|
|
const maximum = config.kind === "room" ? 150 : 159;
|
|
if (text.length > maximum) throw new Error("Notification text exceeds " + maximum + " characters for " + (config.kind === "room" ? "a room post" : "a DM") + ". Shorten the patterns or melody.");
|
|
return text;
|
|
}
|
|
function level(pattern, elapsed) {
|
|
for (let i = 0; i < pattern.length; i++) { if (elapsed < pattern[i]) return i % 2 === 0; elapsed -= pattern[i]; }
|
|
return false;
|
|
}
|
|
function encodeCommand(command, tag) {
|
|
const text = new TextEncoder().encode(tag + "|" + command);
|
|
if (!/^[A-Za-z0-9]{2}$/.test(tag) || text.length + 1 > 176) throw new Error("Command too long or invalid tag.");
|
|
return Uint8Array.from([60, (text.length + 1) & 255, (text.length + 1) >> 8, 0x42, ...text]);
|
|
}
|
|
class FrameDecoder {
|
|
constructor() { this.buffer = []; }
|
|
push(chunk) {
|
|
this.buffer.push(...chunk);
|
|
if (this.buffer.length > 4096) this.buffer = this.buffer.slice(-4096);
|
|
const frames = [];
|
|
while (this.buffer.length) {
|
|
const head = this.buffer.indexOf(62);
|
|
if (head < 0) { this.buffer = []; break; }
|
|
if (head) this.buffer.splice(0, head);
|
|
if (this.buffer.length < 3) break;
|
|
const size = this.buffer[1] + this.buffer[2] * 256;
|
|
if (!size || size > 176) { this.buffer.shift(); continue; }
|
|
if (this.buffer.length < size + 3) break;
|
|
frames.push(Uint8Array.from(this.buffer.splice(0, size + 3).slice(3)));
|
|
}
|
|
return frames;
|
|
}
|
|
}
|
|
class SerialClient {
|
|
constructor(port) { this.port = port; this.decoder = new FrameDecoder(); this.sequence = 0; this.pending = null; }
|
|
async open() {
|
|
await this.port.open({ baudRate: 115200 });
|
|
await this.port.setSignals({ dataTerminalReady: true, requestToSend: false });
|
|
this.reader = this.port.readable.getReader(); this.writer = this.port.writable.getWriter();
|
|
this.reading = this.readLoop();
|
|
}
|
|
async readLoop() {
|
|
try {
|
|
while (true) {
|
|
const { value, done } = await this.reader.read(); if (done) break;
|
|
for (const frame of this.decoder.push(value)) {
|
|
if (frame[0] !== 0x1d || !this.pending) continue;
|
|
const text = new TextDecoder().decode(frame.slice(1));
|
|
if (text.startsWith(this.pending.tag + "|")) this.pending.resolve(text.slice(3));
|
|
}
|
|
}
|
|
} catch (error) { if (this.pending) this.pending.reject(error); }
|
|
finally { if (this.pending) this.pending.reject(new Error("USB connection closed.")); }
|
|
}
|
|
async command(text) {
|
|
if (this.pending) throw new Error("Wait for the current command.");
|
|
const tag = (this.sequence++ % 1296).toString(36).padStart(2, "0");
|
|
let timeout;
|
|
const response = new Promise((resolve, reject) => { this.pending = { tag, resolve, reject }; timeout = setTimeout(() => reject(new Error("Device did not reply. Use Binary Companion USB mode and updated firmware.")), 5000); });
|
|
try { const results = await Promise.all([this.writer.write(encodeCommand(text, tag)), response]); return results[1]; }
|
|
finally { clearTimeout(timeout); this.pending = null; }
|
|
}
|
|
async close() {
|
|
if (this.reader) { await this.reader.cancel(); await this.reading; this.reader.releaseLock(); }
|
|
if (this.writer) this.writer.releaseLock();
|
|
await this.port.close();
|
|
}
|
|
}
|
|
function init(root) {
|
|
if (root.dataset.ready) return; root.dataset.ready = "true";
|
|
const find = role => root.querySelector('[data-role="' + role + '"]');
|
|
const form = find("form"), error = find("error"), output = find("commands"), log = find("device-log");
|
|
const audio = find("preview-audio");
|
|
let client = null, supported = null, previewTimer = null, previewUrl = null, previewVersion = 0;
|
|
const config = () => Object.fromEntries(new FormData(form));
|
|
function render() {
|
|
try {
|
|
output.textContent = commands(config()).join("\n"); error.textContent = "";
|
|
try { find("dm").textContent = notificationText(config()); }
|
|
catch (e) { find("dm").textContent = e.message; }
|
|
}
|
|
catch (e) { output.textContent = ""; find("dm").textContent = ""; error.textContent = e.message; }
|
|
}
|
|
function preset(name) {
|
|
const values = { remote: "inherit", ...EXAMPLES[name] };
|
|
for (const [key, value] of Object.entries(values)) if (form.elements[key]) form.elements[key].value = value;
|
|
render();
|
|
}
|
|
function stopPreview(status = "Stopped") {
|
|
previewVersion++;
|
|
clearInterval(previewTimer); previewTimer = null;
|
|
audio.onerror = null;
|
|
audio.pause(); audio.removeAttribute("src"); audio.load();
|
|
if (previewUrl) { global.URL.revokeObjectURL(previewUrl); previewUrl = null; }
|
|
for (const item of root.querySelectorAll("[data-indicator]")) item.dataset.on = "false";
|
|
find("preview-sound").textContent = status;
|
|
}
|
|
async function preview() {
|
|
stopPreview(); const version = previewVersion, c = config();
|
|
// A local preview does not need a contact/room key or a saved repeat policy.
|
|
const { pulses, notes } = parseOutputs(c), tuneMs = notes.reduce((n, item) => n + item.ms, 0);
|
|
const duration = Math.max(tuneMs, ...Object.values(pulses).map(list => list.reduce((a, b) => a + b, 0))) || 1000;
|
|
// Preview one cycle. Never creates an unbounded oscillator or vibration loop.
|
|
if (notes.length) {
|
|
// Use media playback rather than Web Audio's ambient/ringer channel on iOS.
|
|
previewUrl = global.URL.createObjectURL(new Blob([melodyWav(c.sound)], { type: "audio/wav" }));
|
|
audio.src = previewUrl; audio.loop = false; audio.muted = false; audio.volume = 1;
|
|
find("preview-sound").textContent = "Starting melody...";
|
|
find("preview-time").textContent = "0 / " + duration + " ms";
|
|
audio.onerror = () => {
|
|
if (version !== previewVersion) return;
|
|
stopPreview("Sound unavailable"); error.textContent = "The browser could not play the sound preview.";
|
|
};
|
|
try { await audio.play(); }
|
|
catch (e) {
|
|
// Stop can cancel a pending play() without showing a playback failure.
|
|
if (version !== previewVersion) return;
|
|
stopPreview("Sound unavailable"); throw new Error("Sound preview could not start: " + e.message);
|
|
}
|
|
if (version !== previewVersion) return;
|
|
find("preview-sound").textContent = "Playing melody";
|
|
} else {
|
|
find("preview-sound").textContent = c.sound === "inherit" ? "Choose a melody to preview inherited sound." : "Sound is off for this preview.";
|
|
}
|
|
let finishedAt = null;
|
|
const started = performance.now() - (notes.length ? audio.currentTime * 1000 : 0);
|
|
previewTimer = setInterval(() => {
|
|
if (!root.isConnected) { stopPreview(); return; }
|
|
let elapsed = performance.now() - started;
|
|
if (notes.length) {
|
|
if (!audio.ended) elapsed = audio.currentTime * 1000;
|
|
else {
|
|
if (finishedAt === null) finishedAt = performance.now();
|
|
elapsed = tuneMs + performance.now() - finishedAt;
|
|
}
|
|
}
|
|
for (const name of Object.keys(pulses)) root.querySelector('[data-indicator="' + name + '"]').dataset.on = String(level(pulses[name], elapsed));
|
|
root.querySelector('[data-indicator="screen"]').dataset.on = String(c.screen === "on");
|
|
find("preview-time").textContent = Math.min(Math.round(elapsed), duration) + " / " + duration + " ms";
|
|
if (elapsed >= duration && (!notes.length || audio.ended)) stopPreview("Cycle finished");
|
|
}, 10);
|
|
}
|
|
const append = text => { log.textContent += text + "\n"; log.scrollTop = log.scrollHeight; };
|
|
async function run(text) {
|
|
if (!client) throw new Error("Connect a device over USB first.");
|
|
append("> " + text); const reply = await client.command(text); append(reply);
|
|
if (/^(Error|ERR|Unknown command|Not Supported)/.test(reply)) throw new Error(reply);
|
|
return reply;
|
|
}
|
|
async function act(action) {
|
|
if (action === "preview-stop") { stopPreview(); return; }
|
|
error.textContent = "";
|
|
const buttons = root.querySelectorAll("button[data-action]"); buttons.forEach(b => b.disabled = b.dataset.action !== "preview-stop");
|
|
try {
|
|
if (action === "copy") { await navigator.clipboard.writeText(commands(config()).join("\n"));find("copy-status").textContent = "Copied"; }
|
|
if (action === "preview") await preview();
|
|
if (action === "connect") {
|
|
if (!navigator.serial) throw new Error("USB testing requires Chrome or Edge with Web Serial support.");
|
|
if (client) { await client.close(); client = null; }
|
|
const port = await navigator.serial.requestPort();client = new SerialClient(port);await client.open();
|
|
const state = await run("get notify");
|
|
const match = state.match(/supported=(\d+)/);if (!match) throw new Error("This firmware does not expose notification settings.");
|
|
supported = Number(match[1]);
|
|
find("device-status").textContent = "USB connected. Available alerts: " + Object.keys(OUTPUTS).filter(k => supported & OUTPUTS[k]).join(", ");
|
|
await run("get notify.gpio.pins");
|
|
}
|
|
if (action === "disconnect" && client) { await client.close();client = null;supported = null;find("device-status").textContent = "Device disconnected"; }
|
|
if (action === "apply" || action === "apply-test") {
|
|
const c = config(), list = commands(c);
|
|
if (!client || supported === null) throw new Error("Connect updated Companion firmware first.");
|
|
for (const name of Object.keys(OUTPUTS)) if (!["off", "inherit"].includes(c[name]) && !(supported & OUTPUTS[name])) throw new Error(name + " is unavailable on this device. Choose off or inherit.");
|
|
for (const text of list) await run(text);
|
|
if (action === "apply-test") await run("notify.test " + selector(c));
|
|
}
|
|
if (action === "test") await run("notify.test " + selector(config()));
|
|
if (action === "stop") await run("notify.stop");
|
|
if (action === "delete") await run("notify.delete " + selector(config()));
|
|
} catch (e) { error.textContent = e.message; }
|
|
finally { buttons.forEach(b => b.disabled = false); }
|
|
}
|
|
form.addEventListener("input", render);
|
|
root.addEventListener("click", event => {
|
|
const example = event.target.closest("[data-example]"); if (example) preset(example.dataset.example);
|
|
const button = event.target.closest("[data-action]"); if (button) act(button.dataset.action);
|
|
});
|
|
global.addEventListener("pagehide", () => stopPreview());preset("food");
|
|
}
|
|
const api = { pulse, melody, melodyWav, parseOutputs, selector, commands, notificationText, level, encodeCommand, FrameDecoder, SerialClient, EXAMPLES, init };
|
|
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
|
global.MeshCoreNotifications = api;
|
|
if (typeof document !== "undefined") {
|
|
const boot = () => document.querySelectorAll("[data-notification-builder]").forEach(init);
|
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
|
|
if (typeof document$ !== "undefined") document$.subscribe(boot);
|
|
}
|
|
})(typeof globalThis !== "undefined" ? globalThis : this);
|