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. +
@@ -22,6 +23,12 @@ stop policy. USB testing uses Binary Companion mode at 115200 baud. + +

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.

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.

+ 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.


   
@@ -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()