Add channel-nine-only notification builder preset

This commit is contained in:
mikecarper
2026-10-05 18:41:27 -07:00
parent 46e6bb7b71
commit 1db09f41ac
6 changed files with 195 additions and 9 deletions
+23 -3
View File
@@ -5,7 +5,8 @@
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" }
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" },
channel9: { label: "Only channel 9", kind: "channel", id: "9", when: "any", vibration: "off", led: "off", sound: "ch9:d=8,o=5,b=180:c,e,g", screen: "off", gpio: "off", repeat: "1", gap: "500", stop: "button", quietOthers: "on" }
};
function pulse(text) {
if (text === "off" || text === "inherit") return [];
@@ -91,6 +92,14 @@
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.");
if (config.quietOthers === "on") {
if (config.kind !== "channel") throw new Error("Silencing other message alerts needs a channel exception.");
// This is a shared RULE, not master off: the selected channel can still
// override it. Copy and USB Save must apply the same two-rule recipe.
result.push("set notify.enabled on");
for (const name of Object.keys(OUTPUTS)) result.push("set notify." + name + " all off");
result.push("set notify.repeat all 1", "set notify.gap all 500", "set notify.stop all button");
}
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]);
@@ -104,6 +113,7 @@
return result;
}
function notificationText(config) {
if (config.quietOthers === "on") throw new Error("Use CLI commands or USB Save for the silent default and channel exception; a notification DM cannot configure these rules.");
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]);
@@ -185,6 +195,10 @@
let client = null, supported = null, previewTimer = null, previewUrl = null, previewVersion = 0;
const config = () => Object.fromEntries(new FormData(form));
function render() {
const quiet = form.elements.quietOthers;
quiet.disabled = form.elements.kind.value !== "channel";
if (quiet.disabled) quiet.checked = false;
find("quiet-warning").hidden = !quiet.checked;
try {
output.textContent = commands(config()).join("\n"); error.textContent = "";
try { find("dm").textContent = notificationText(config()); }
@@ -193,8 +207,11 @@
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;
const values = { remote: "inherit", quietOthers: "off", ...EXAMPLES[name] };
for (const [key, value] of Object.entries(values)) if (form.elements[key]) {
if (key === "quietOthers") form.elements[key].checked = value === "on";
else form.elements[key].value = value;
}
render();
}
function stopPreview(status = "Stopped") {
@@ -296,6 +313,9 @@
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.");
// Resolve/check numeric channel slots before changing the shared
// default: a missing exception channel must not leave all alerts mute.
if (c.quietOthers === "on") await run("get notify.sound " + selector(c));
for (const text of list) await run(text);
if (action === "apply-test") await run("notify.test " + selector(c));
}
@@ -7,6 +7,8 @@
.notification-builder small { font-size: .7rem; font-weight: 400; color: var(--md-default-fg-color--light); }
.notification-builder input, .notification-builder select { width: 100%; min-width: 0; border: 1px solid var(--md-default-fg-color--lighter); border-radius: 6px; padding: .6rem; background: var(--md-default-bg-color); color: var(--md-default-fg-color); font: inherit; font-weight: 400; }
.notification-builder input:focus, .notification-builder select:focus { outline: 2px solid var(--md-primary-fg-color); outline-offset: 2px; }
.notification-builder .notification-checkbox { flex-direction: row; align-items: center; margin-top: 1rem; }
.notification-builder .notification-checkbox input { width: auto; flex: 0 0 auto; padding: 0; }
.notification-builder button { border: 1px solid var(--md-primary-fg-color); border-radius: 6px; padding: .5rem .75rem; margin: .2rem; cursor: pointer; color: var(--md-primary-fg-color); font: inherit; }
.notification-builder button:hover { background: var(--md-primary-fg-color); color: var(--md-primary-bg-color); }
.notification-builder button:disabled { opacity: .5; cursor: wait; }
+14 -1
View File
@@ -13,6 +13,7 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud.
<button type="button" data-example="find">Find my node</button>
<button type="button" data-example="food">Food order ready</button>
<button type="button" data-example="vip">VIP chat</button>
<button type="button" data-example="channel9">Only channel 9</button>
</div>
<form data-role="form" onsubmit="return false">
<section class="notification-card">
@@ -22,6 +23,12 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud.
<label>Contact/room key or channel slot<input name="id" autocomplete="off" placeholder="Full contact/room public key, or channel slot such as 3"></label>
<label>Companion client status<select name="when"><option value="any">Connected or disconnected</option><option value="connected">Connected</option><option value="disconnected">Disconnected</option></select></label>
</div>
<label class="notification-checkbox"><input type="checkbox" name="quietOthers" value="on">Silence other message alerts (channel exception)</label>
<p data-role="quiet-warning" hidden>This saves a silent default plus the selected channel's rule.
It also silences ordinary DMs and room-message alerts. Existing specific/state rules
and authorized <code>!notify</code> strings can still alert; review the
<a href="#only-channel-9-alerts-all-other-channels-silent">channel 9 example and caveats</a>.
Copy commands and USB Save include both rules; preview only plays the exception.</p>
<p>Connected means an active phone/app, USB, WiFi, Ethernet or serial
companion client. It selects a message rule; connecting or disconnecting
alone does not create an alert. Contact matching uses the full public key.
@@ -106,7 +113,9 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud.
<p>Save writes the generated commands in order and stops at the first device
error; earlier successful settings remain saved. A test with a connection
suffix selects that profile even over USB. Stop-on-connect still watches
real subsequent connections. USB testing requires Chrome or Edge.</p>
real subsequent connections. The silence-other-alerts option saves both
the silent default and the selected channel rule. Deleting the selected
rule does not delete that default. USB testing requires Chrome or Edge.</p>
<pre data-role="device-log" class="notification-log" aria-live="polite"></pre>
</section>
</div>
@@ -247,6 +256,10 @@ This example makes message alerts silent by default, then plays one short
melody for channel 9. It works both with and without a connected companion
client and requires a device with a buzzer. Messages still arrive normally;
this changes the node's alerts, not the phone app's notification settings.
Use the **Only channel 9** button above to load this setup into the builder.
You can change the channel slot or alert pattern before copying or saving.
Uncheck **Silence other message alerts** to generate only the selected rule;
this does not remove a silent default already saved on the node.
`channel:9` means the firmware's **zero-based slot 9** (the tenth slot), not
a channel named `ch9`. Configure that slot before running these commands.
+2 -2
View File
@@ -111,7 +111,7 @@ theme:
- search.suggest
extra_css:
- _stylesheets/notification_builder.css
- _stylesheets/notification_builder.css?v=20261006-channel9
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.css
- _stylesheets/extra.css
- _stylesheets/firmware_picker.css?v=20261001-feedback-1
@@ -122,7 +122,7 @@ extra_css:
- _stylesheets/esp32_partition_catalog.css?v=20261001-1
extra_javascript:
- _javascript/notification_builder.js?v=20260930-audio-start
- _javascript/notification_builder.js?v=20261006-channel9
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
- _javascript/firmware_picker.js?v=20261001-feedback-1
- _javascript/telemetry_decoder.js
+36
View File
@@ -16,6 +16,10 @@ for (const config of Object.values(builder.EXAMPLES)) {
const complete = {...config, id: config.kind === "contact" ? "01".repeat(32) : config.id};
const commands = builder.commands(complete);
assert.ok(commands.every(cmd => cmd.length + 4 <= 175));
if (config.quietOthers === "on") {
assert.throws(() => builder.notificationText(complete), /CLI|USB/);
continue;
}
const dm = builder.notificationText(complete);
assert.ok(dm.startsWith("!notify "));assert.ok(!dm.includes("gpio="));assert.ok(dm.length <= 159);
}
@@ -28,6 +32,38 @@ assert.equal(builder.notificationText(longPost).length, 151);
assert.throws(() => builder.notificationText({...longPost, kind: "room"}), /150 characters for a room post/);
assert.throws(() => builder.commands({...builder.EXAMPLES.food, remote: "on"}));
assert.throws(() => builder.commands({...vip, id: "1234"}));
// The channel-only example installs a quiet default before its exception.
const quietPrefix = [
"set notify.enabled on",
"set notify.vibration all off", "set notify.sound all off",
"set notify.led all off", "set notify.screen all off", "set notify.gpio all off",
"set notify.repeat all 1", "set notify.gap all 500", "set notify.stop all button"
];
const channel9 = builder.EXAMPLES.channel9;
assert.equal(channel9.kind, "channel");assert.equal(channel9.id, "9");
assert.equal(channel9.when, "any");assert.equal(channel9.quietOthers, "on");
assert.equal(channel9.sound, "ch9:d=8,o=5,b=180:c,e,g");
assert.equal(channel9.repeat, "1");assert.equal(channel9.gap, "500");assert.equal(channel9.stop, "button");
for (const name of ["vibration", "led", "screen", "gpio"]) assert.equal(channel9[name], "off");
const exceptionOnly = builder.commands({...channel9, quietOthers: "off"});
const onlyChannel9 = builder.commands(channel9);
assert.deepEqual(onlyChannel9, [...quietPrefix, ...exceptionOnly]);
assert.deepEqual(exceptionOnly, builder.commands({...channel9, quietOthers: undefined}));
assert.deepEqual(exceptionOnly, [
"set notify.vibration channel:9 off", "set notify.sound on",
"set notify.sound channel:9 ch9:d=8,o=5,b=180:c,e,g",
"set notify.led channel:9 off", "set notify.screen channel:9 off",
"set notify.gpio channel:9 off", "set notify.repeat channel:9 1",
"set notify.gap channel:9 500", "set notify.stop channel:9 button"
]);
assert.deepEqual(builder.commands({...channel9, id: "8"}), [
...quietPrefix, ...exceptionOnly.map(command => command.replace("channel:9", "channel:8"))
]);
for (const kind of ["all", "contact", "room"]) {
assert.throws(() => builder.commands({...channel9, kind, id: "01".repeat(32)}), /channel/i);
}
assert.throws(() => builder.notificationText(channel9), /CLI|USB/);
assert.ok(builder.notificationText({...channel9, quietOthers: "off"}).startsWith("!notify "));
// Local examples must play before choosing a recipient. Saved rules still need a key.
assert.equal(builder.parseOutputs(builder.EXAMPLES.vip).notes.length, 4);
assert.throws(() => builder.commands(builder.EXAMPLES.vip), /full 64-digit/);
+118 -3
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env python3
"""Optional browser checks for notification audio. Requires Playwright and its browser.
"""Optional browser checks for notification audio/editor. Requires Playwright and its browser.
Use MESHCORE_PREVIEW_BROWSER=webkit to also check the Safari engine, or set
Use MESHCORE_PREVIEW_BROWSER=webkit to also check the Safari engine, set
MESHCORE_PREVIEW_EXECUTABLE to use an installed browser executable, or set
MESHCORE_NOTIFICATION_PREVIEW_URL to check the deployed editor.
"""
@@ -57,7 +58,9 @@ class NotificationAudioTest(unittest.TestCase):
cls.playwright = sync_playwright().start()
engine = getattr(cls.playwright, os.environ.get("MESHCORE_PREVIEW_BROWSER", "chromium"))
try:
cls.browser = engine.launch(headless=True)
executable = os.environ.get("MESHCORE_PREVIEW_EXECUTABLE")
options = {"executable_path": executable} if executable else {}
cls.browser = engine.launch(headless=True, **options)
except Exception:
cls.playwright.stop()
raise
@@ -92,6 +95,118 @@ class NotificationAudioTest(unittest.TestCase):
# Inspect decoded audio at the speaker output, not just a successful play() call.
self.page.wait_for_function("previewAudioStats.rms > 0.05")
def test_only_channel_9_button_sets_quiet_default_and_editable_exception(self):
self.page.locator('[data-example="channel9"]').click()
expected = {
"kind": "channel", "id": "9", "when": "any", "vibration": "off",
"led": "off", "sound": "ch9:d=8,o=5,b=180:c,e,g", "screen": "off",
"gpio": "off", "repeat": "1", "gap": "500", "stop": "button",
}
for name, value in expected.items():
self.assertEqual(self.page.locator('[name="' + name + '"]').input_value(), value)
self.assertTrue(self.page.locator('[name="quietOthers"]').is_checked())
self.assertTrue(self.page.locator('[data-role="quiet-warning"]').is_visible())
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
commands = self.page.locator('[data-role="commands"]').inner_text().splitlines()
self.assertEqual(commands[:9], self.quiet_prefix())
self.assertIn("set notify.sound channel:9 ch9:d=8,o=5,b=180:c,e,g", commands)
self.assertNotIn("!notify ", self.page.locator('[data-role="dm"]').inner_text())
self.assertRegex(self.page.locator('[data-role="dm"]').inner_text(), "CLI|USB")
self.page.locator('[name="id"]').fill("8")
edited = self.page.locator('[data-role="commands"]').inner_text().splitlines()
self.assertEqual(edited[:9], self.quiet_prefix())
self.assertIn("set notify.sound channel:8 ch9:d=8,o=5,b=180:c,e,g", edited)
self.assertNotIn("channel:9", "\n".join(edited))
self.page.locator('[name="quietOthers"]').uncheck()
normal = self.page.locator('[data-role="commands"]').inner_text()
self.assertNotIn(" all ", normal)
self.assertNotIn("set notify.enabled on", normal)
self.assertTrue(self.page.locator('[data-role="dm"]').inner_text().startswith("!notify "))
self.assertFalse(self.page.locator('[data-role="quiet-warning"]').is_visible())
self.page.locator('[data-example="channel9"]').click()
self.page.locator('[name="kind"]').select_option("all")
self.assertFalse(self.page.locator('[name="quietOthers"]').is_checked())
self.assertTrue(self.page.locator('[name="quietOthers"]').is_disabled())
self.assertNotIn("set notify.enabled on", self.page.locator('[data-role="commands"]').inner_text())
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
def test_switching_example_clears_quiet_default(self):
for example in ("food", "find", "vip"):
self.page.locator('[data-example="channel9"]').click()
self.assertTrue(self.page.locator('[name="quietOthers"]').is_checked())
self.page.locator('[data-example="' + example + '"]').click()
self.assertFalse(self.page.locator('[name="quietOthers"]').is_checked())
if example == "vip":
self.page.locator('[name="id"]').fill("01" * 32)
commands = self.page.locator('[data-role="commands"]').inner_text()
self.assertNotIn("set notify.enabled on", commands)
self.assertNotIn("set notify.sound all off", commands)
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
self.assertTrue(self.page.locator('[data-role="dm"]').inner_text().startswith("!notify "))
@staticmethod
def quiet_prefix():
return [
"set notify.enabled on", "set notify.vibration all off",
"set notify.sound all off", "set notify.led all off",
"set notify.screen all off", "set notify.gpio all off",
"set notify.repeat all 1", "set notify.gap all 500", "set notify.stop all button",
]
def mock_usb(self, reject_channel=False):
self.page.evaluate("""rejectChannel => {
let incoming, active = 0;
window.notificationUsbMock = { commands: [], maxActive: 0, replies: 0 };
const port = {
readable: new ReadableStream({ start(controller) { incoming = controller; } }),
writable: new WritableStream({ write(bytes) {
const text = new TextDecoder().decode(bytes.slice(4));
const tag = text.slice(0, 2), command = text.slice(3);
notificationUsbMock.commands.push(command);
notificationUsbMock.maxActive = Math.max(notificationUsbMock.maxActive, ++active);
setTimeout(() => {
let value = command === 'get notify' ? 'supported=31' : 'OK';
if (command.startsWith('get notify.sound channel:'))
value = rejectChannel ? 'Error: channel slot is not configured' : 'inherit';
const body = new TextEncoder().encode(tag + '|' + value);
active--; notificationUsbMock.replies++;
incoming.enqueue(Uint8Array.from([62, body.length + 1, 0, 0x1d, ...body]));
}, 10);
} }),
async open() {}, async setSignals() {}, async close() {},
};
Object.defineProperty(navigator, 'serial', { configurable: true, value: {
async requestPort() { return port; }
} });
}""", reject_channel)
def test_only_channel_9_usb_save_sends_default_then_exception_sequentially(self):
self.mock_usb()
self.page.locator('[data-example="channel9"]').click()
expected = self.page.locator('[data-role="commands"]').inner_text().splitlines()
self.assertEqual(expected[:9], self.quiet_prefix())
self.click("connect")
self.page.wait_for_function("notificationUsbMock.replies === 2 && !document.querySelector('[data-action=apply]').disabled")
self.click("apply")
self.page.wait_for_function("notificationUsbMock.replies === " + str(len(expected) + 3) + " && !document.querySelector('[data-action=apply]').disabled")
self.assertEqual(self.page.evaluate("notificationUsbMock.commands"), ["get notify", "get notify.gpio.pins", "get notify.sound channel:9"] + expected)
self.assertEqual(self.page.evaluate("notificationUsbMock.maxActive"), 1)
self.assertEqual(self.page.locator('[data-role="error"]').inner_text(), "")
self.click("disconnect")
self.page.wait_for_function("document.querySelector('[data-role=device-status]').textContent === 'Device disconnected'")
def test_unconfigured_channel_is_rejected_before_quiet_default_is_written(self):
self.mock_usb(reject_channel=True)
self.page.locator('[data-example="channel9"]').click()
self.click("connect")
self.page.wait_for_function("notificationUsbMock.replies === 2 && !document.querySelector('[data-action=apply]').disabled")
self.click("apply")
self.page.wait_for_function("notificationUsbMock.replies === 3 && !document.querySelector('[data-action=apply]').disabled")
self.assertEqual(self.page.evaluate("notificationUsbMock.commands"), ["get notify", "get notify.gpio.pins", "get notify.sound channel:9"])
self.assertIn("channel slot is not configured", self.page.locator('[data-role="error"]').inner_text())
self.click("disconnect")
self.page.wait_for_function("document.querySelector('[data-role=device-status]').textContent === 'Device disconnected'")
def test_examples_play_audible_melodies_once_without_a_recipient(self):
for index, example in enumerate(("food", "find", "vip"), 1):
self.page.locator('[data-example="' + example + '"]').click()