From 1db09f41ac3206d2a4be0bed902ab0b56ef3209d Mon Sep 17 00:00:00 2001
From: mikecarper
Date: Mon, 5 Oct 2026 18:41:27 -0700
Subject: [PATCH] Add channel-nine-only notification builder preset
---
docs/_javascript/notification_builder.js | 26 ++++-
docs/_stylesheets/notification_builder.css | 2 +
docs/notifications.md | 15 ++-
mkdocs.yml | 4 +-
test/test_notification_builder.js | 36 ++++++
test/test_notification_preview.py | 121 ++++++++++++++++++++-
6 files changed, 195 insertions(+), 9 deletions(-)
diff --git a/docs/_javascript/notification_builder.js b/docs/_javascript/notification_builder.js
index 5673de894..91fcbcf62 100644
--- a/docs/_javascript/notification_builder.js
+++ b/docs/_javascript/notification_builder.js
@@ -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));
}
diff --git a/docs/_stylesheets/notification_builder.css b/docs/_stylesheets/notification_builder.css
index a6d5ab11d..74d2861ba 100644
--- a/docs/_stylesheets/notification_builder.css
+++ b/docs/_stylesheets/notification_builder.css
@@ -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; }
diff --git a/docs/notifications.md b/docs/notifications.md
index dce53ce03..06e6aa198 100644
--- a/docs/notifications.md
+++ b/docs/notifications.md
@@ -13,6 +13,7 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud.
+
@@ -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.
diff --git a/mkdocs.yml b/mkdocs.yml
index ed408c15b..05b26ec09 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -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
diff --git a/test/test_notification_builder.js b/test/test_notification_builder.js
index 358972166..071d81e07 100644
--- a/test/test_notification_builder.js
+++ b/test/test_notification_builder.js
@@ -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/);
diff --git a/test/test_notification_preview.py b/test/test_notification_preview.py
index 108d731a8..23a65cb50 100644
--- a/test/test_notification_preview.py
+++ b/test/test_notification_preview.py
@@ -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()