From 4bc391ef368f3bf48ba8033c568d206256e36197 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 30 Sep 2026 03:44:22 -0700 Subject: [PATCH] Add programmable Companion alerts and trusted DM and room notifications --- .github/workflows/run-unit-tests.yml | 2 + docs/_javascript/notification_builder.js | 250 +++++++++++++++ docs/_stylesheets/notification_builder.css | 19 ++ docs/cli_commands.md | 5 + docs/companion_radio_full.md | 5 + docs/index.md | 1 + docs/notifications.md | 237 ++++++++++++++ examples/companion_radio/AbstractUITask.h | 18 +- examples/companion_radio/CompanionFeatures.h | 10 + examples/companion_radio/MyMesh.cpp | 69 +++- examples/companion_radio/MyMesh.h | 22 ++ .../NotificationSettingsFile.h | 62 ++++ examples/companion_radio/Notifications.cpp | 81 +++++ examples/companion_radio/ui-new/UITask.cpp | 44 ++- examples/companion_radio/ui-new/UITask.h | 12 +- examples/companion_radio/ui-orig/UITask.cpp | 49 ++- examples/companion_radio/ui-orig/UITask.h | 8 + examples/companion_radio/ui-tiny/UITask.cpp | 60 +++- examples/companion_radio/ui-tiny/UITask.h | 12 +- mkdocs.yml | 2 + platformio.ini | 2 + src/helpers/CompanionNotificationPolicy.cpp | 176 +++++++++++ src/helpers/CompanionNotificationPolicy.h | 294 ++++++++++++++++++ src/helpers/ui/DRV2605Vibration.cpp | 7 + src/helpers/ui/DRV2605Vibration.h | 1 + src/helpers/ui/DisplayDriver.cpp | 6 +- src/helpers/ui/DisplayDriver.h | 6 + src/helpers/ui/buzzer.cpp | 17 +- src/helpers/ui/buzzer.h | 2 + .../test_companion_notifications.cpp | 178 +++++++++++ test/test_notification_builder.js | 77 +++++ test/test_notification_settings.py | 134 ++++++++ variants/lilygo_techo_card/platformio.ini | 2 + 33 files changed, 1846 insertions(+), 24 deletions(-) create mode 100644 docs/_javascript/notification_builder.js create mode 100644 docs/_stylesheets/notification_builder.css create mode 100644 docs/notifications.md create mode 100644 examples/companion_radio/NotificationSettingsFile.h create mode 100644 examples/companion_radio/Notifications.cpp create mode 100644 src/helpers/CompanionNotificationPolicy.cpp create mode 100644 src/helpers/CompanionNotificationPolicy.h create mode 100644 test/test_companion_notifications/test_companion_notifications.cpp create mode 100644 test/test_notification_builder.js create mode 100644 test/test_notification_settings.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 2b18b4712..45f4b9d64 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -246,6 +246,8 @@ jobs: - name: Verify Companion Bluetooth settings and WebConfig run: | + node test/test_notification_builder.js + python3 -B test/test_notification_settings.py python3 -B test/test_display_power.py python3 -B test/test_companion_inbox.py python3 -B test/test_heltec_r8_external_power.py diff --git a/docs/_javascript/notification_builder.js b/docs/_javascript/notification_builder.js new file mode 100644 index 000000000..9b9ba55b7 --- /dev/null +++ b/docs/_javascript/notification_builder.js @@ -0,0 +1,250 @@ +(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 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 = []; + for (const name of ["vibration", "led"]) pulse(config[name]); + 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."); + pulse(match[2]); + } + 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(" "); + if (text.length > 159) throw new Error("Notification text exceeds 159 characters. 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"); + let client = null, supported = null, previewTimer = null, audio = null, oscillators = []; + 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() { + clearInterval(previewTimer); previewTimer = null; + for (const oscillator of oscillators) { try { oscillator.stop(); } catch (_) {} } oscillators = []; + for (const item of root.querySelectorAll("[data-indicator]")) item.dataset.on = "false"; + } + function preview() { + stopPreview(); const c = config(); commands(c); + const pulses = { vibration: pulse(c.vibration), led: pulse(c.led), gpio: pulse(c.gpio.includes(":") ? c.gpio.split(":")[1] : c.gpio) }; + const notes = melody(c.sound), 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 && (global.AudioContext || global.webkitAudioContext)) { + audio = audio || new (global.AudioContext || global.webkitAudioContext)(); audio.resume(); + let offset = 0; + for (const note of notes) { + if (note.hz) { + const oscillator = audio.createOscillator(), gain = audio.createGain(); + oscillator.type = "square"; oscillator.frequency.value = note.hz; gain.gain.value = 0.03; + oscillator.connect(gain); gain.connect(audio.destination); + oscillator.start(audio.currentTime + offset / 1000); oscillator.stop(audio.currentTime + (offset + note.ms) / 1000); + oscillators.push(oscillator); + } + offset += note.ms; + } + } + const started = performance.now(); + previewTimer = setInterval(() => { + const elapsed = performance.now() - started; + 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) stopPreview(); + }, 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) { + error.textContent = ""; + const buttons = root.querySelectorAll("button[data-action]"); buttons.forEach(b => b.disabled = true); + try { + if (action === "copy") { await navigator.clipboard.writeText(commands(config()).join("\n"));find("copy-status").textContent = "Copied"; } + if (action === "preview") preview(); + if (action === "preview-stop") stopPreview(); + 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, 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); diff --git a/docs/_stylesheets/notification_builder.css b/docs/_stylesheets/notification_builder.css new file mode 100644 index 000000000..a6d5ab11d --- /dev/null +++ b/docs/_stylesheets/notification_builder.css @@ -0,0 +1,19 @@ +.notification-builder { max-width: 960px; margin: 1.5rem auto; } +.notification-card { border: 1px solid var(--md-default-fg-color--lightest); border-radius: 12px; padding: 1.1rem; margin: 1rem 0; background: var(--md-default-bg-color); } +.notification-card h2 { margin: 0 0 1rem; font-size: 1.15rem; font-weight: 650; } +.notification-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } +.notification-wide { grid-column: 1 / -1; } +.notification-builder label { display: flex; flex-direction: column; gap: .4rem; font-weight: 600; } +.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 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; } +.notification-examples, .notification-buttons, .notification-indicators { display: flex; gap: .4rem; flex-wrap: wrap; } +.notification-indicators span { padding: .6rem 1rem; border-radius: 20px; border: 2px solid var(--md-default-fg-color--lighter); } +.notification-indicators [data-on="true"] { background: #166534; color: #fff; border-color: #22c55e; } +.notification-error { color: #c62828; min-height: 1.2rem; font-weight: 600; } +.notification-log { max-height: 280px; overflow: auto; white-space: pre-wrap; } +.notification-builder pre code { white-space: pre; } +@media (max-width: 600px) { .notification-grid { grid-template-columns: 1fr; } .notification-wide { grid-column: auto; } .notification-card { padding: .8rem; } } diff --git a/docs/cli_commands.md b/docs/cli_commands.md index c656364de..e88feea4c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -882,6 +882,11 @@ build flag is needed. ## Set display modes and timeouts +Companion message sound, vibration, LED, screen and GPIO rules have separate +CLI controls. See the [notification builder and CLI reference](notifications.md), +including per-contact/channel rules, trusted-contact/room `!notify` permission, +and USB test controls. + Display-equipped Companions, repeaters, room servers, and sensors have four saved settings. The WebConfig **Display** card exposes the same controls: diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 174c1c719..1f1995104 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -1,5 +1,10 @@ # Full Companion +Configure message alerts with the [Companion notification builder](notifications.md). +It generates CLI rules for contacts, rooms and channels, previews patterns, and +can apply/test them over USB. Trusted contacts and rooms can send `!notify` +strings with no GPIO and a 15-second playback limit. + For runtime on/off commands and board exceptions, see [feature switches by role](role_feature_switches.md). Open the [USB web console](https://flasher.meshcore.io/console) for the default ASCII terminal. diff --git a/docs/index.md b/docs/index.md index 0173103f1..7de249cb8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,6 +11,7 @@ Below are a few quick start guides. - [Full Companion feature switches](./full_companion_features.md) - [Frequently Asked Questions](./faq.md) - [Firmware Picker](./firmware_picker.md) +- [Companion Notification Builder](./notifications.md) - [Temporary Radio Test Command Generator](./preset_test.md) - [CLI Commands](./cli_commands.md) - [LoRa CLI Host Service](./host_cli_service.md) diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 000000000..cd73eb113 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,237 @@ +# Companion notification builder + +Make a rule, preview it here, then copy the CLI commands or apply and test it +over USB. The device needs firmware containing the new notification commands. +The programmable alert engine is enabled on ESP32, nRF52 and RP2040 builds. +Compact STM32WL Companions retain their existing alerts because the engine +does not fit their 224 KiB application partition. +Browser previews show one cycle; device tests use the configured repeat and +stop policy. USB testing uses Binary Companion mode at 115200 baud. + +
+
+ + + +
+
+
+

Who should trigger the alert?

+
+ + + +
+

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. + Channel slots resolve to the channel's key when the rule is saved.

+
+
+

What should the device do?

+
+ + + + + +
+

50,300,40,20,500 means on for 50 ms, off for 300 ms, + on for 40 ms, off for 20 ms, then on for 500 ms. A rule controls only + outputs physically supported by its firmware. The USB connection reports + the supported outputs and approved GPIO pins before applying anything.

+
+
+

Repeating and stopping

+
+ + + +
+

Node connected stops an active alert when a companion client connects. + Never disables button/connection dismissal; a finite repeat count still + finishes. notify.stop always stops playback. A new matching + message replaces the current alert.

+
+
+

Allow notification strings from this contact or room

+ +

Only applies to a contact or room server's full public key, without a connection + condition. An allowed contact can DM the notification string below, or + it can be posted in an allowed room. Trusting a room allows its posters + to send alerts through that server. + GPIO, stop-policy and permission changes are forbidden in DMs or room posts. The device + stops playback after at most 15 seconds, including infinite repeats. + Incoming strings do not save settings and obey the recipient's master switches.

+
+
+ +
+

Preview one cycle

+
VibrationLEDGPIOScreen
+

Ready

+ + +

Timing here is a visual approximation. Firmware schedules outputs using + millisecond timestamps without blocking radio work; loop and hardware + latency can delay an edge. A haptic motor also takes time to start/stop.

+
+
+

Device commands

+
+ +

Notification DM or room post

+
+

The recipient must first grant your contact or room permission. Send the string + as an ordinary DM or room post; it contains no GPIO action. Long patterns may need + shortening to fit a 159-character message.

+
+
+

Try it on your device

+

No device connected

+
+ + + + + + + +
+

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.

+

+  
+
+ +## CLI reference + +Settings apply immediately and survive reboot and filesystem-preserving +updates. They live in a separate checked file, `/notify_prefs`, with an atomic +replacement and backup. There are 12 rule slots. Existing alerts stay in use +until a rule or master control enables the new system. Unmatched messages keep +their existing alert behavior, subject to the master switches. + +### Master controls + +```text +get notify +set notify.enabled on +set notify.sound on +set notify.vibration on +set notify.led on +set notify.screen on +set notify.gpio off +get notify.gpio.pins +``` + +Each output has `get notify.` and `set notify. on|off`. Master +off cannot be overridden by a contact, channel or incoming notification DM. +`set notify.enabled off` disables custom rules and DM actions and returns to +the existing alert behavior. Supported-output bits in `get notify` are +vibration=1, sound=2, LED=4, screen=8 and GPIO=16. GPIO uses the same board pin +approval as repeater GPIO control and is inactive by default. + +### Rules + +Targets are `all`, `contact:<64 hexadecimal public-key digits>`, +`room:<64 hexadecimal room server public-key digits>`, and +`channel:`. A channel's 32-digit key also works. Add `@connected` or +`@disconnected` to select the client state; no suffix matches either state. + +```text +set notify.vibration channel:3 50,300,40,20,500 +set notify.sound channel:3 order:d=8,o=5,b=180:c,e,g,4c6 +set notify.led channel:3 100,100,100,500 +set notify.screen channel:3 on +set notify.gpio channel:3 22:50,300,50 +set notify.repeat channel:3 forever +set notify.gap channel:3 2000 +set notify.stop channel:3 button +get notify.vibration channel:3 +notify.test channel:3 +notify.stop +notify.delete channel:3 +get notify.rules +get notify.rules 0 +``` + +The GPIO example works only if pin 22 appears in `get notify.gpio.pins`. +Pulse lists have up to 12 positive durations of 1-60000 ms each. GPIO output is +active-high, inactive-low. Sound is one note at a time, not chords. RTTTL +requires `name:d=<1|2|4|8|16|32>,o=<4..7>,b=<25..900>:notes`; names use up to +12 ASCII letters/digits/underscores. Melodies are at most 63 characters and +60 seconds. Notes use `a` through `g`, `p` for rest, optional duration/octave, +`#` for sharps on a/c/d/f/g, and one `.` for a dotted duration. For example +`16c#6` and `4g.5` are valid. Invalid melodies are rejected before the buzzer +library sees them. This ASCII format matches the installed NonBlockingRTTTL +driver's pitch table; musical staff symbols and polyphony are not supported. + +For any rule output, `off` explicitly disables it and `inherit` removes that +field's override. Rules combine per field: shared `all`, shared state rule, +contact/channel rule, then contact/channel state rule. A matching rule replaces +the old message sound/vibration; unspecified outputs are off unless inherited +from a shared rule. An unspecified screen keeps its normal display policy. +`screen on|off` temporarily overrides that policy and restores it when the +alert ends. A selected LED pattern temporarily takes ownership from the usual +status heartbeat; the master LED switch also controls that heartbeat. + +Finite repeats are `1..65535`; `forever` loops until dismissed. Gap is +`1..60000` ms after the longest output sequence. Stop modes are `button`, +`connected`, and `never`. Triple-press sound/vibration mute controls also +update their notification master switch where those buttons are supported. +Continuous alerts prevent MCU sleep so their timers can run. + +### Contact and room permission for notification strings + +On the recipient, replace `PUBLIC_KEY` with the sender's complete public key: + +```text +set notify.remote contact:PUBLIC_KEY on +get notify.remote contact:PUBLIC_KEY +set notify.remote contact:PUBLIC_KEY off +set notify.remote room:ROOM_PUBLIC_KEY on +``` + +Permission defaults to off, is saved per contact or room key, and cannot be granted +to `all`, channels or connection-state variants. The identity comes from the +authenticated received DM, not a key written inside its text. The permission +alone does not replace the contact's normal message alerts. A trusted room's +forwarded `!notify` posts use the room server's complete key for permission, +not the post author's four-byte prefix. Trusting a room allows its posters to +send notifications; trusting a chat contact alone does not authorize their +posts forwarded by an untrusted room. Both plain DMs and signed room posts +pass through the same validation and 15-second playback cap. + +The allowed sender can send: + +```text +!notify vibration=50,300,40,20,500 led=100,100,100,500 sound=order:d=8,o=5,b=180:c,e,g screen=on repeat=forever gap=2000 +``` + +Allowed fields are `vibration`, `sound`, `led`, `screen`, `repeat` and `gap`. +The whole notification string is validated before any output changes. +Unknown/duplicate fields, GPIO, stop modes, permission changes and `inherit` +are rejected. The command cannot change saved rules. Button dismissal is +always allowed, `notify.stop` works, and playback stops at 15000 ms even when +the requested pattern or repeat count is longer. Unapproved or invalid +notification strings remain ordinary message text. Repeated delivery of the +same recently received post or DM does not restart its playback deadline. +Channel messages cannot execute +notification strings. Receiver master switches apply even while connected. + +## Examples + +- **Find my node:** use an allowed sender to DM a short sound/LED pattern, or + use authorized remote CLI to run a saved `notify.test all@disconnected` rule. + A local saved find rule can repeat until a companion client connects. A + notification DM always ends within 15 seconds. +- **Food truck pager:** use a private order channel rule that repeats until + the customer presses the button, or let the customer grant the order desk's + contact permission and send the notification DM when the order is ready. +- **VIP chat:** match the VIP's full public key and set a distinctive melody + and vibration. Add a connected profile with sound/vibration off if the phone + should handle notifications while attached. Permission to send notification + strings is separate from a VIP's ordinary message rule. diff --git a/examples/companion_radio/AbstractUITask.h b/examples/companion_radio/AbstractUITask.h index f0b0cb18c..208555ad1 100644 --- a/examples/companion_radio/AbstractUITask.h +++ b/examples/companion_radio/AbstractUITask.h @@ -35,6 +35,10 @@ protected: MultiSerialInterface* _interfaceManager; UIShutdownGuard* _shutdownGuard; bool _connected; + bool _message_notification_override = false; + bool _message_notification_screen_override = false; + bool _notification_led_owned = false; + uint8_t _notification_outputs = 31; AbstractUITask(mesh::MainBoard* board, MultiSerialInterface* interfaceManager) : _board(board), _interfaceManager(interfaceManager), @@ -49,9 +53,21 @@ protected: - bool shouldWakeDisplayForMessage() const { return !hasConnection(); } + bool shouldWakeDisplayForMessage() const { + return (_notification_outputs & 8) && !_message_notification_screen_override && !hasConnection(); + } public: + void setMessageNotificationOverride(bool managed, bool screen = false) { + _message_notification_override = managed; + _message_notification_screen_override = managed && screen; + } + void setNotificationLedOwnership(bool owned) { _notification_led_owned = owned; } + void setNotificationOutputs(uint8_t outputs) { _notification_outputs = outputs; } + virtual uint8_t notificationCapabilities() const { return 0; } + virtual void notificationVibration(bool on) { (void)on; } + virtual void notificationMelody(const char* text) { (void)text; } + virtual void notificationScreen(int8_t mode) { (void)mode; } void setShutdownGuard(UIShutdownGuard* guard) { _shutdownGuard = guard; } void setHasConnection(bool connected) { _connected = connected; } bool hasConnection() const { return _interfaceManager->isConnected(); } diff --git a/examples/companion_radio/CompanionFeatures.h b/examples/companion_radio/CompanionFeatures.h index 45d98ec51..f73b9a109 100644 --- a/examples/companion_radio/CompanionFeatures.h +++ b/examples/companion_radio/CompanionFeatures.h @@ -2,6 +2,16 @@ #include #include +// STM32WL has only 224 KiB of application flash. Keep its established image +// within that boundary; larger targets provide the programmable alert engine. +#ifndef COMPANION_FEATURE_NOTIFICATIONS + #if defined(STM32_PLATFORM) + #define COMPANION_FEATURE_NOTIFICATIONS 0 + #else + #define COMPANION_FEATURE_NOTIFICATIONS 1 + #endif +#endif + // The role terminal is also used by WebConfig on WiFi-only Companions. Its // parser and reply tracking do not require a physical USB transport. #if defined(ENABLE_USB_INTERFACE) \ diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 66fece1fa..2845d56ba 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -1,4 +1,5 @@ #include +#include "NotificationSettingsFile.h" #include "MyMesh.h" #include #include "CompanionBluetooth.h" @@ -1482,17 +1483,29 @@ void MyMesh::queueMessage(const ContactInfo &from, uint8_t txt_type, mesh::Packe } #endif +#if COMPANION_FEATURE_NOTIFICATIONS + const bool plain_notification = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN; + const bool remote_notification = plain_notification + && _notifications.remoteMessage(from.id.pub_key, text, millis(), sender_timestamp); + const bool notification_managed = remote_notification || (plain_notification + && _notifications.message(from.id.pub_key, nullptr, _serial->isConnected(), millis())); + if (_ui) _ui->setMessageNotificationOverride(notification_managed, _notifications.overridesScreen()); +#else + const bool notification_managed = false; +#endif + #ifdef DISPLAY_CLASS // we only want to show text messages on display, not cli data bool should_display = txt_type == TXT_TYPE_PLAIN || txt_type == TXT_TYPE_SIGNED_PLAIN; if (should_display && _ui) { _ui->newMsg(path_len, from.name, text, offline_queue_len, -1, nullptr, queued ? offline_queue_len - 1 : -1); - if (!_serial->isConnected()) { + if (!notification_managed && !_serial->isConnected()) { _ui->notify(UIEventType::contactMessage); } } #endif + if (_ui) _ui->setMessageNotificationOverride(false); } static uint16_t emergencyClientRepeatKey(const mesh::Packet* packet) { @@ -1700,13 +1713,19 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe i += tlen; const bool queued = addToOfflineQueue(out_frame, i); +#if COMPANION_FEATURE_NOTIFICATIONS + const bool notification_managed = _notifications.message(nullptr, channel.secret, _serial->isConnected(), millis()); + if (_ui) _ui->setMessageNotificationOverride(notification_managed, _notifications.overridesScreen()); +#else + const bool notification_managed = false; +#endif if (_serial->isConnected()) { uint8_t frame[1]; - frame[0] = PUSH_CODE_MSG_WAITING; // send push 'tickle' + frame[0] = PUSH_CODE_MSG_WAITING; _serial->writeFrame(frame, 1); } else { #ifdef DISPLAY_CLASS - if (_ui) _ui->notify(UIEventType::channelMessage); + if (_ui && !notification_managed) _ui->notify(UIEventType::channelMessage); #endif } @@ -1736,6 +1755,8 @@ void MyMesh::onChannelMessageRecv(const mesh::GroupChannel &channel, mesh::Packe } #endif + if (_ui) _ui->setMessageNotificationOverride(false); + if (pkt->isRouteFlood() && is_emergency_channel) { bool zero_path = pkt->getPathHashCount() == 0; uint16_t key = emergencyClientRepeatKey(pkt); @@ -2379,6 +2400,18 @@ void MyMesh::begin(bool has_display, bool radio_available) { #endif ); +#if COMPANION_FEATURE_NOTIFICATIONS + auto& notification_settings = _notifications.settings(); + FILESYSTEM* notification_fs = _store->getPrimaryFS(); + if (!(mesh::notify::readSettings(notification_fs, "/notify_prefs", notification_settings) + && _notifications.validSettings(notification_settings))) { + if (!(mesh::notify::readSettings(notification_fs, "/notify_prefs.bak", notification_settings) + && _notifications.validSettings(notification_settings))) + notification_settings = mesh::notify::Settings(); + } + + #endif + // load persisted prefs const bool prefs_ready = _store->loadPrefs(_prefs, sensors.node_lat, sensors.node_lon); @@ -9028,6 +9061,13 @@ void MyMesh::handleTerminalCommand(char* command) { terminalOutput().print(" memory\r\n"); #endif terminalOutput().print(" get display.rotation\r\n"); + #if COMPANION_FEATURE_NOTIFICATIONS + terminalOutput().print(" get notify / get notify.rules / get notify.gpio.pins\r\n"); + terminalOutput().print(" set notify.{sound|vibration|led|screen|gpio} on|off\r\n"); + terminalOutput().print(" set notify. \r\n"); + terminalOutput().print(" set notify.remote on|off (!notify, max 15s, no GPIO)\r\n"); + terminalOutput().print(" notify.test / notify.stop / notify.delete \r\n"); + #endif terminalOutput().print(" get display.inbox\r\n"); terminalOutput().print(" set display.inbox \r\n"); terminalOutput().print(" set display.rotation <0|90|180|270>\r\n"); @@ -9338,6 +9378,7 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, command = profile_command; } if (sender_timestamp == 0 && handleDirectCommand(command, reply, reply_capacity)) return true; + if (handleNotificationCommand(command, reply, reply_capacity)) return true; if (mesh::companion::handleRetryCommand( _prefs, command, reply, reply_capacity, @@ -9868,6 +9909,15 @@ void MyMesh::checkSerialInterface() { } void MyMesh::loop() { +#if COMPANION_FEATURE_NOTIFICATIONS + _notifications.loop(millis(), _serial->isConnected()); +#if defined(PIN_USER_BTN) && defined(USER_BTN_PRESSED) + const bool notification_button_down = digitalRead(PIN_USER_BTN) == USER_BTN_PRESSED; + if (notification_button_down && !_notification_button_down) _notifications.button(); + _notification_button_down = notification_button_down; +#endif +#endif + _radio_profiles.loop(); #if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID) if (_mqtt_bridge) _mqtt_bridge->servicePendingClockCorrection(); @@ -9971,7 +10021,15 @@ void MyMesh::loop() { } #ifdef DISPLAY_CLASS - if (_ui) _ui->setHasConnection(_serial->isConnected()); + if (_ui) { + _ui->setHasConnection(_serial->isConnected()); + #if COMPANION_FEATURE_NOTIFICATIONS + const auto& notifications = _notifications.settings(); + _ui->setNotificationOutputs(notifications.enabled ? notifications.outputs : 31); + _ui->setNotificationLedOwnership(_notifications.ownsLed()); + if (notifications.enabled && !(notifications.outputs & mesh::notify::Led)) pulse(mesh::notify::Led, false, -1); + #endif + } #endif } @@ -9992,6 +10050,9 @@ bool MyMesh::advert() { // To check if there is pending work bool MyMesh::hasPendingWork() const { +#if COMPANION_FEATURE_NOTIFICATIONS + if (_notifications.active()) return true; +#endif if (isDualRadioActive()) return true; if (_radio_available && (radio_driver.isWatchdogObserving() diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 57af597cb..66fff5fe4 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -26,6 +26,7 @@ #include "DataStore.h" #include +#include #include "NodePrefs.h" #if defined(ESP32_PLATFORM) && defined(WIFI_SSID) && !defined(WEBCONFIG_DISABLED) @@ -161,6 +162,9 @@ struct DiscoveredNode { #endif class MyMesh : public BaseChatMesh, public DataStoreHost, public UIShutdownGuard +#if COMPANION_FEATURE_NOTIFICATIONS + , public mesh::notify::Sink, public mesh::notify::Store +#endif #if COMPANION_FEATURE_TEXT_TERMINAL , public ContactVisitor #endif @@ -246,6 +250,20 @@ public: #endif void loop(); + #if COMPANION_FEATURE_NOTIFICATIONS + uint8_t capabilities() const override; + bool gpioAvailable(uint8_t pin) const override; + void pulse(mesh::notify::Output output, bool on, int8_t pin) override; + void melody(const char* text) override; + void screen(int8_t mode) override; + bool save(const mesh::notify::Settings& settings) override; + bool notificationButton() { return _notifications.button(); } + void setNotificationOutputMute(mesh::notify::Output output, bool muted); + #else + bool notificationButton() { return false; } + void setNotificationOutputMute(mesh::notify::Output, bool) {} + #endif + bool handleNotificationCommand(const char* command, char* reply, size_t size); void handleCmdFrame(size_t len); bool advert(); void enterCLIRescue(); @@ -560,6 +578,10 @@ private: mesh::RadioProfileCLI _radio_profiles; uint16_t _temp_radio_preamble = 0; CompanionNodePrefs _prefs; + #if COMPANION_FEATURE_NOTIFICATIONS + mesh::notify::Controller _notifications{*this, *this}; + bool _notification_button_down = false; + #endif #ifdef COMPANION_MESH_CLOCK_SYNC ArduinoMillis _clock_sync_millis; ClientACL _clock_sync_acl; diff --git a/examples/companion_radio/NotificationSettingsFile.h b/examples/companion_radio/NotificationSettingsFile.h new file mode 100644 index 000000000..818b8f841 --- /dev/null +++ b/examples/companion_radio/NotificationSettingsFile.h @@ -0,0 +1,62 @@ +#pragma once +#include +#include + +namespace mesh { namespace notify { +// Separate versioned storage keeps old CompanionNodePrefs layouts unchanged. +// Record size detects an incompatible layout rather than applying stale pins. +inline uint32_t checksum(const Settings& prefs) { + uint32_t h=2166136261UL; + const uint8_t* p=reinterpret_cast(&prefs); + for(size_t i=0;iopen(path,"r");if(!file)return false; +#endif + uint8_t head[6]={};uint32_t digest=0; + bool ok=file.size()==sizeof(prefs)+sizeof(head)+sizeof(digest) + &&file.read(head,sizeof(head))==sizeof(head)&&!memcmp(head,"NTF1",4) + &&(uint16_t(head[4])|(uint16_t(head[5])<<8))==sizeof(prefs) + &&file.read(reinterpret_cast(&prefs),sizeof(prefs))==sizeof(prefs) + &&file.read(reinterpret_cast(&digest),sizeof(digest))==sizeof(digest); + file.close();return ok&&digest==checksum(prefs); +} +inline bool writeSettings(FILESYSTEM* fs,const Settings& prefs) { + if(!fs)return false; + const char* path="/notify_prefs",*temporary="/notify_prefs.tmp",*backup="/notify_prefs.bak"; + fs->remove(temporary); + if(fs->exists(temporary))return false; +#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) + File file(*fs);if(!file.open(temporary,FILE_O_WRITE))return false; +#elif defined(ESP32_PLATFORM) + File file=fs->open(temporary,"w",true);if(!file)return false; +#else + File file=fs->open(temporary,"w");if(!file)return false; +#endif + uint8_t head[]={ 'N','T','F','1',uint8_t(sizeof(prefs)),uint8_t(sizeof(prefs)>>8) }; + uint32_t digest=checksum(prefs); + bool ok=file.write(head,sizeof(head))==sizeof(head) + &&file.write(reinterpret_cast(&prefs),sizeof(prefs))==sizeof(prefs) + &&file.write(reinterpret_cast(&digest),sizeof(digest))==sizeof(digest); + file.close(); + Settings checked; + if(!ok||!readSettings(fs,temporary,checked)||memcmp(&checked,&prefs,sizeof(prefs)))return false; + if(fs->exists(path)) { + if(readSettings(fs,path,checked)) { + if(fs->exists(backup)&&!fs->remove(backup))return false; + if(!fs->rename(path,backup))return false; + } else if(!fs->remove(path))return false; // Keep the good recovery copy. + } + if(!fs->rename(temporary,path)) { + if(fs->exists(backup))fs->rename(backup,path); + return false; + } + fs->remove(backup);return true; +} +static_assert(sizeof(Settings)<=3072,"Keep notification settings within the nRF52 RAM budget"); +} } diff --git a/examples/companion_radio/Notifications.cpp b/examples/companion_radio/Notifications.cpp new file mode 100644 index 000000000..d3f4b329f --- /dev/null +++ b/examples/companion_radio/Notifications.cpp @@ -0,0 +1,81 @@ +#include "MyMesh.h" +#include "target.h" +#include "NotificationSettingsFile.h" + +#if COMPANION_FEATURE_NOTIFICATIONS +uint8_t MyMesh::capabilities() const { + uint8_t bits = _ui ? _ui->notificationCapabilities() : 0; +#if defined(PIN_STATUS_LED) || defined(STATUS_LED_RGB) + bits |= mesh::notify::Led; +#endif + for(unsigned pin=0;pin<64;++pin)if(gpioAvailable(pin)) { bits|=mesh::notify::Gpio;break; } + return bits; +} +bool MyMesh::gpioAvailable(uint8_t pin) const { return board.isUserGpioAvailable(pin); } +void MyMesh::pulse(mesh::notify::Output output,bool on,int8_t pin) { + if(output==mesh::notify::Gpio && pin>=0 && gpioAvailable(pin)) { + pinMode(pin,OUTPUT);digitalWrite(pin,on?HIGH:LOW); + } else if(output==mesh::notify::Vibration) { + if(_ui)_ui->notificationVibration(on); + } else if(output==mesh::notify::Led) { +#ifdef STATUS_LED_RGB + analogWrite(PIN_STATUS_LED_R,on?255:0); + analogWrite(PIN_STATUS_LED_G,on?90:0); + analogWrite(PIN_STATUS_LED_B,0); +#elif defined(PIN_STATUS_LED) + pinMode(PIN_STATUS_LED,OUTPUT); +#ifdef LED_STATE_ON + digitalWrite(PIN_STATUS_LED,on?LED_STATE_ON:!LED_STATE_ON); +#else + digitalWrite(PIN_STATUS_LED,on?HIGH:LOW); +#endif +#endif + } +} +void MyMesh::melody(const char* text) { if(_ui)_ui->notificationMelody(text); } +void MyMesh::screen(int8_t mode) { if(_ui)_ui->notificationScreen(mode); } +bool MyMesh::save(const mesh::notify::Settings& settings) { + return mesh::notify::writeSettings(_store->getPrimaryFS(),settings); +} +void MyMesh::setNotificationOutputMute(mesh::notify::Output output,bool muted) { + if(!_notifications.settings().enabled)return; + char command[40],reply[160]; + snprintf(command,sizeof(command),"set notify.%s %s", + output==mesh::notify::Sound?"sound":"vibration",muted?"off":"on"); + _notifications.handle(command,reply,sizeof(reply),millis(),_serial->isConnected()); +} +bool MyMesh::handleNotificationCommand(const char* command,char* reply,size_t size) { + if(strncmp(command,"set notify.",11)&&strncmp(command,"get notify",10) + &&strncmp(command,"notify.",7))return false; + // Resolve channel slots now and store their secret, so a replaced slot does + // not inherit a previous channel's VIP/page rule. Full keys also work. + char normalized[240]; + const char* token=strstr(command,"channel:"); + if(token) { + const char* value=token+8;const char* end=value; + while(*end && *end!=' ' && *end!='@')++end; + if(end-value<4) { + char digits[4]={};memcpy(digits,value,end-value);uint32_t index; + ChannelDetails details; + if(!mesh::notify::unsignedValue(digits,index,MAX_GROUP_CHANNELS-1) + ||!getChannel(index,details)||!details.name[0]) { + snprintf(reply,size,"Error: channel slot not found");return true; + } + char key[33];mesh::Utils::toHex(key,details.channel.secret,16); + int prefix=value-command; + int n=snprintf(normalized,sizeof(normalized),"%.*s%s%s",prefix,command,key,end); + if(n<0||size_t(n)>=sizeof(normalized)) { snprintf(reply,size,"Error: command too long");return true; } + command=normalized; + } + } + return _notifications.handle(command,reply,size,millis(),_serial->isConnected()); +} + +#else +bool MyMesh::handleNotificationCommand(const char* command,char* reply,size_t size) { + if(strncmp(command,"set notify.",11)&&strncmp(command,"get notify",10) + &&strncmp(command,"notify.",7))return false; + snprintf(reply,size,"Error: programmable alerts unavailable on this compact STM32 build"); + return true; +} +#endif diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ec8776f55..673fd8900 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1950,7 +1950,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode buzzer.startup(); #endif -#ifdef PIN_VIBRATION +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.begin(); #endif @@ -2016,7 +2016,7 @@ void UITask::renderMessageSummary(DisplayDriver& display) const { void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) -switch(t){ +if (_notification_outputs & mesh::notify::Sound) switch(t){ case UIEventType::contactMessage: // gemini's pick buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); @@ -2037,7 +2037,7 @@ switch(t){ #ifdef PIN_VIBRATION // Trigger vibration for all UI events except none - if (t != UIEventType::none) { + if ((_notification_outputs & mesh::notify::Vibration) && t != UIEventType::none) { vibration.trigger(); } #endif @@ -2089,6 +2089,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, } void UITask::userLedHandler() { + if (_notification_led_owned || !(_notification_outputs & mesh::notify::Led)) return; #ifdef PIN_STATUS_LED int cur_time = millis(); if (cur_time > next_led_change) { @@ -2482,7 +2483,7 @@ void UITask::loop() { #endif } -#ifdef PIN_VIBRATION +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.loop(); #endif @@ -2540,6 +2541,7 @@ void UITask::getTouchControls(mesh::ui::TouchSplitSelector& transport_touch_sele #endif char UITask::checkDisplayOn(char c) { + the_mesh.notificationButton(); if (_display != NULL) { const bool was_on = _display->isOn(); _display->wake(mesh::ui::DisplayWake::Button); @@ -2644,8 +2646,42 @@ void UITask::toggleBuzzer() { buzzer.quiet(true); } _node_prefs->buzzer_quiet = buzzer.isQuiet(); + the_mesh.setNotificationOutputMute(mesh::notify::Sound, _node_prefs->buzzer_quiet); the_mesh.savePrefs(); showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800); _next_refresh = 0; // trigger refresh #endif } + + +uint8_t UITask::notificationCapabilities() const { + uint8_t bits = 0; +#ifdef PIN_BUZZER + bits |= mesh::notify::Sound; +#endif +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + bits |= mesh::notify::Vibration; +#endif +#ifdef MESHCORE_HAS_REAL_DISPLAY + if (_display) bits |= mesh::notify::Screen; +#endif + return bits; +} +void UITask::notificationMelody(const char* text) { +#ifdef PIN_BUZZER + if (text) buzzer.playNotification(text); else buzzer.stop(); +#endif +} +void UITask::notificationScreen(int8_t mode) { + if (!_display) return; + _display->notificationPower(mode); + _next_refresh = 0; +} +void UITask::notificationVibration(bool on) { +#ifdef HAS_DRV2605 + vibration.pulse(on); +#elif defined(PIN_VIBRATION) + vibration.stop(); + digitalWrite(PIN_VIBRATION, on ? HIGH : LOW); +#endif +} diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 327b56021..e53d28c94 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -18,7 +18,9 @@ #ifdef PIN_BUZZER #include #endif -#ifdef PIN_VIBRATION +#ifdef HAS_DRV2605 + #include +#elif defined(PIN_VIBRATION) #include #endif @@ -32,7 +34,9 @@ class UITask : public AbstractUITask { #ifdef PIN_BUZZER genericBuzzer buzzer; #endif -#ifdef PIN_VIBRATION +#ifdef HAS_DRV2605 + DRV2605Vibration vibration; +#elif defined(PIN_VIBRATION) GenericVibration vibration; #endif unsigned long _next_refresh; @@ -187,6 +191,10 @@ public: const char* channel_name = nullptr, int queue_index = -1) override; void notify(UIEventType t = UIEventType::none) override; + uint8_t notificationCapabilities() const override; + void notificationVibration(bool on) override; + void notificationMelody(const char* text) override; + void notificationScreen(int8_t mode) override; void loop() override; void shutdown(bool restart = false); diff --git a/examples/companion_radio/ui-orig/UITask.cpp b/examples/companion_radio/ui-orig/UITask.cpp index b44c38df2..001308167 100644 --- a/examples/companion_radio/ui-orig/UITask.cpp +++ b/examples/companion_radio/ui-orig/UITask.cpp @@ -127,6 +127,8 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode #ifdef HAS_DRV2605 vibration.begin(); vibration.quiet(_node_prefs->vibe_quiet); +#elif defined(PIN_VIBRATION) + vibration.begin(); #endif // Initialize digital button if available @@ -208,7 +210,7 @@ bool UITask::shouldPlayMessageTone() const { void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) -switch(t){ +if (_notification_outputs & mesh::notify::Sound) switch(t){ case UIEventType::contactMessage: // gemini's pick if (shouldPlayMessageTone()) { @@ -255,8 +257,8 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, (void)channel_name; _msgcount = msgcount; -#ifdef HAS_DRV2605 - vibration.trigger(); // vibrate even while the app is connected (honors quiet + cooldown) +#if defined(HAS_DRV2605) || defined(PIN_VIBRATION) + if (!_message_notification_override && (_notification_outputs & mesh::notify::Vibration)) vibration.trigger(); #endif if (path_len == 0xFF) { @@ -459,6 +461,7 @@ static void statusLedWheel(uint8_t pos) { // smooth hue sweep, 0..255 #endif void UITask::userLedHandler() { + if (_notification_led_owned || !(_notification_outputs & mesh::notify::Led)) return; #ifdef STATUS_LED_RGB static bool booted = false; static bool flourish_active = false; @@ -647,6 +650,9 @@ void UITask::servicePairingState() { } void UITask::loop() { +#ifdef PIN_VIBRATION + vibration.loop(); +#endif servicePairingState(); #ifdef PIN_USER_BTN @@ -686,6 +692,7 @@ void UITask::loop() { } void UITask::handleButtonAnyPress() { + the_mesh.notificationButton(); MESH_DEBUG_PRINTLN("UITask: any press triggered"); // called on any button press before other events, to wake up the display quickly // do not refresh the display here, as it may block the button handler @@ -749,6 +756,8 @@ void UITask::handleButtonTriplePress() { _node_prefs->vibe_quiet = (mode & 1) ? 1 : 0; buzzer.quiet(_node_prefs->buzzer_quiet); vibration.quiet(_node_prefs->vibe_quiet); + the_mesh.setNotificationOutputMute(mesh::notify::Sound, _node_prefs->buzzer_quiet); + the_mesh.setNotificationOutputMute(mesh::notify::Vibration, _node_prefs->vibe_quiet); // audible/tactile confirmation of the new mode (no screen on some boards) if (!_node_prefs->buzzer_quiet) notify(UIEventType::ack); if (!_node_prefs->vibe_quiet) vibration.trigger(true); @@ -770,6 +779,7 @@ void UITask::handleButtonTriplePress() { sprintf(_alert, "Buzzer: OFF"); } _node_prefs->buzzer_quiet = buzzer.isQuiet(); + the_mesh.setNotificationOutputMute(mesh::notify::Sound, _node_prefs->buzzer_quiet); the_mesh.savePrefs(); _need_refresh = true; #endif @@ -806,3 +816,36 @@ void UITask::handleButtonLongPress() { shutdown(); } } + + +uint8_t UITask::notificationCapabilities() const { + uint8_t bits = 0; +#ifdef PIN_BUZZER + bits |= mesh::notify::Sound; +#endif +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + bits |= mesh::notify::Vibration; +#endif +#ifdef MESHCORE_HAS_REAL_DISPLAY + if (_display) bits |= mesh::notify::Screen; +#endif + return bits; +} +void UITask::notificationMelody(const char* text) { +#ifdef PIN_BUZZER + if (text) buzzer.playNotification(text); else buzzer.stop(); +#endif +} +void UITask::notificationScreen(int8_t mode) { + if (!_display) return; + _display->notificationPower(mode); + _need_refresh = true; +} +void UITask::notificationVibration(bool on) { +#ifdef HAS_DRV2605 + vibration.pulse(on); +#elif defined(PIN_VIBRATION) + vibration.stop(); + digitalWrite(PIN_VIBRATION, on ? HIGH : LOW); +#endif +} diff --git a/examples/companion_radio/ui-orig/UITask.h b/examples/companion_radio/ui-orig/UITask.h index 09b60dcbc..3cc366e65 100644 --- a/examples/companion_radio/ui-orig/UITask.h +++ b/examples/companion_radio/ui-orig/UITask.h @@ -16,6 +16,8 @@ #ifdef HAS_DRV2605 #include +#elif defined(PIN_VIBRATION) + #include #endif class UITask : public AbstractUITask { @@ -26,6 +28,8 @@ class UITask : public AbstractUITask { #endif #ifdef HAS_DRV2605 DRV2605Vibration vibration; +#elif defined(PIN_VIBRATION) + GenericVibration vibration; #endif unsigned long _next_refresh; CompanionNodePrefs* _node_prefs; @@ -103,6 +107,10 @@ public: const char* channel_name = nullptr, int queue_index = -1) override; void notify(UIEventType t = UIEventType::none) override; + uint8_t notificationCapabilities() const override; + void notificationVibration(bool on) override; + void notificationMelody(const char* text) override; + void notificationScreen(int8_t mode) override; void loop() override; void shutdown(bool restart = false); diff --git a/examples/companion_radio/ui-tiny/UITask.cpp b/examples/companion_radio/ui-tiny/UITask.cpp index a98bfe668..649426561 100644 --- a/examples/companion_radio/ui-tiny/UITask.cpp +++ b/examples/companion_radio/ui-tiny/UITask.cpp @@ -33,6 +33,8 @@ namespace { +constexpr unsigned SCREEN_SENSOR_BUFFER_BYTES = 200; + mesh::ui::RadioProfileSystemStatus radioProfileSystemStatus( const CompanionNodePrefs& prefs) { mesh::ui::RadioProfileSystemStatus status; @@ -221,7 +223,7 @@ class HomeScreen : public UIScreen { public: HomeScreen(UITask* task, mesh::RTCClock* rtc, SensorManager* sensors, CompanionNodePrefs* node_prefs) : _task(task), _rtc(rtc), _sensors(sensors), _node_prefs(node_prefs), _page(0), - _shutdown_init(false), sensors_lpp(200) { + _shutdown_init(false), sensors_lpp(SCREEN_SENSOR_BUFFER_BYTES) { resetRadioProfileDisplayPage(); } @@ -616,6 +618,18 @@ public: }; #endif +#if defined(MESH_COMPANION_SCREEN_STARTUP_BYTES) +// This UI allocates three small screens, one sensor buffer, and no history +// table. Check the concrete objects instead of borrowing ui-new's allowance. +static_assert(sizeof(SplashScreen) + sizeof(HomeScreen) +#if UI_SMALL_MESSAGE_FONT == 1 + + sizeof(TinyMessageScreen) +#endif + + SCREEN_SENSOR_BUFFER_BYTES + 4 * 16 + <= MESH_COMPANION_SCREEN_STARTUP_BYTES, + "Increase MESH_COMPANION_SCREEN_STARTUP_BYTES and its RAM budget"); +#endif + void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNodePrefs* node_prefs) { _display = display; _sensors = sensors; @@ -645,7 +659,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, CompanionNode buzzer.startup(); #endif -#ifdef PIN_VIBRATION +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.begin(); #endif @@ -667,7 +681,7 @@ void UITask::showAlert(const char* text, int duration_millis) { void UITask::notify(UIEventType t) { #if defined(PIN_BUZZER) -switch(t){ +if (_notification_outputs & mesh::notify::Sound) switch(t){ case UIEventType::contactMessage: // gemini's pick buzzer.play("MsgRcv3:d=4,o=6,b=200:32e,32g,32b,16c7"); @@ -688,7 +702,7 @@ switch(t){ #ifdef PIN_VIBRATION // Trigger vibration for all UI events except none - if (t != UIEventType::none) { + if ((_notification_outputs & mesh::notify::Vibration) && t != UIEventType::none) { vibration.trigger(); } #endif @@ -729,6 +743,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, } void UITask::userLedHandler() { + if (_notification_led_owned || !(_notification_outputs & mesh::notify::Led)) return; #ifdef PIN_STATUS_LED int cur_time = millis(); if (cur_time > next_led_change) { @@ -979,7 +994,7 @@ void UITask::loop() { #endif } -#ifdef PIN_VIBRATION +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) vibration.loop(); #endif @@ -1011,6 +1026,7 @@ void UITask::loop() { } char UITask::checkDisplayOn(char c) { + the_mesh.notificationButton(); if (_display != NULL) { const bool was_on = _display->isOn(); _display->wake(mesh::ui::DisplayWake::Button); @@ -1090,8 +1106,42 @@ void UITask::toggleBuzzer() { buzzer.quiet(true); } _node_prefs->buzzer_quiet = buzzer.isQuiet(); + the_mesh.setNotificationOutputMute(mesh::notify::Sound, _node_prefs->buzzer_quiet); the_mesh.savePrefs(); showAlert(buzzer.isQuiet() ? "Buzzer: OFF" : "Buzzer: ON", 800); _next_refresh = 0; // trigger refresh #endif } + + +uint8_t UITask::notificationCapabilities() const { + uint8_t bits = 0; +#ifdef PIN_BUZZER + bits |= mesh::notify::Sound; +#endif +#if defined(PIN_VIBRATION) || defined(HAS_DRV2605) + bits |= mesh::notify::Vibration; +#endif +#ifdef MESHCORE_HAS_REAL_DISPLAY + if (_display) bits |= mesh::notify::Screen; +#endif + return bits; +} +void UITask::notificationMelody(const char* text) { +#ifdef PIN_BUZZER + if (text) buzzer.playNotification(text); else buzzer.stop(); +#endif +} +void UITask::notificationScreen(int8_t mode) { + if (!_display) return; + _display->notificationPower(mode); + _next_refresh = 0; +} +void UITask::notificationVibration(bool on) { +#ifdef HAS_DRV2605 + vibration.pulse(on); +#elif defined(PIN_VIBRATION) + vibration.stop(); + digitalWrite(PIN_VIBRATION, on ? HIGH : LOW); +#endif +} diff --git a/examples/companion_radio/ui-tiny/UITask.h b/examples/companion_radio/ui-tiny/UITask.h index da81a97bc..483724b9a 100644 --- a/examples/companion_radio/ui-tiny/UITask.h +++ b/examples/companion_radio/ui-tiny/UITask.h @@ -18,7 +18,9 @@ #ifdef PIN_BUZZER #include #endif -#ifdef PIN_VIBRATION +#ifdef HAS_DRV2605 + #include +#elif defined(PIN_VIBRATION) #include #endif @@ -32,7 +34,9 @@ class UITask : public AbstractUITask { #ifdef PIN_BUZZER genericBuzzer buzzer; #endif -#ifdef PIN_VIBRATION +#ifdef HAS_DRV2605 + DRV2605Vibration vibration; +#elif defined(PIN_VIBRATION) GenericVibration vibration; #endif unsigned long _next_refresh; @@ -125,6 +129,10 @@ public: const char* channel_name = nullptr, int queue_index = -1) override; void notify(UIEventType t = UIEventType::none) override; + uint8_t notificationCapabilities() const override; + void notificationVibration(bool on) override; + void notificationMelody(const char* text) override; + void notificationScreen(int8_t mode) override; void loop() override; void shutdown(bool restart = false); diff --git a/mkdocs.yml b/mkdocs.yml index 059419e87..0a452a773 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,7 @@ theme: - search.suggest extra_css: + - _stylesheets/notification_builder.css - https://unpkg.com/leaflet@1.9.4/dist/leaflet.css - _stylesheets/extra.css - _stylesheets/firmware_picker.css?v=20260929-1 @@ -25,6 +26,7 @@ extra_css: - _stylesheets/preset_test.css?v=20260923-2 extra_javascript: + - _javascript/notification_builder.js - https://unpkg.com/leaflet@1.9.4/dist/leaflet.js - _javascript/firmware_picker.js?v=20260929-4 - _javascript/telemetry_decoder.js diff --git a/platformio.ini b/platformio.ini index 8ea76d472..73afa4ec7 100644 --- a/platformio.ini +++ b/platformio.ini @@ -185,6 +185,7 @@ build_flags = ${arduino_base.build_flags} ; PendSV reaches this FreeRTOS function from assembly, outside the LTO graph. -Wl,--undefined=vTaskSwitchContext -D NRF52_PLATFORM + -D USER_GPIO_CONTROL=1 -D LFS_NO_ASSERT=1 -D EXTRAFS=1 -D MESH_NRF52_LOOP_STACK_WORDS=2048 @@ -457,6 +458,7 @@ build_src_filter = +<../src/helpers/ota/OtaTinf.c> +<../src/helpers/ota/detools/detools.c> +<../src/helpers/UserGpio.cpp> + +<../src/helpers/CompanionNotificationPolicy.cpp> +<../src/helpers/ConfigSerializer.cpp> +<../src/helpers/ArduinoSerialInterface.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> diff --git a/src/helpers/CompanionNotificationPolicy.cpp b/src/helpers/CompanionNotificationPolicy.cpp new file mode 100644 index 000000000..c1e025a1c --- /dev/null +++ b/src/helpers/CompanionNotificationPolicy.cpp @@ -0,0 +1,176 @@ +#include "CompanionNotificationPolicy.h" + +namespace mesh { namespace notify { +namespace { +void valueText(const Rule& r,uint16_t f,char* out,size_t size) { + if(!(r.fields&f)) { snprintf(out,size,"inherit");return; } + if(f==FVibe)formatPulse(r.vibration,out,size); + else if(f==FLed)formatPulse(r.led,out,size); + else if(f==FSound)snprintf(out,size,"%s",r.sound[0]?r.sound:"off"); + else if(f==FScreen)snprintf(out,size,"%s",r.screen?"on":"off"); + else if(f==FGpio) { + if(r.pin<0)snprintf(out,size,"off"); + else { size_t n=snprintf(out,size,"%d:",int(r.pin));if(n1||(s.outputs&~31))return false; + for(unsigned i=0;i1)return false;if(!r.used)continue; + if(r.selector.kind>Channel||r.selector.when>Offline||r.fields>511||r.stop>Never||r.remote>1 + ||!r.gap||r.gap>60000||r.screen<-1||r.screen>1||r.pin<-1||r.pin>63)return false; + if((r.fields&FRemote)&&(r.selector.kind!=Contact||r.selector.when!=Any))return false; + const Pulse* patterns[]={&r.vibration,&r.led,&r.gpio}; + for(const Pulse* p:patterns) { + if(p->count>MAX_PULSES)return false; + for(unsigned j=0;jcount;++j)if(!p->ms[j]||p->ms[j]>60000)return false; + } + uint32_t total; + if(!memchr(r.sound,0,sizeof(r.sound))||!soundDuration(r.sound,total))return false; + // A pin valid on an older image may now belong to GPS or storage. + if(r.pin>=0&&!sink.gpioAvailable(r.pin))return false; + for(unsigned j=0;j(memchr(p,'=',end-p)); + if(!equal||equal-p>=16||end-equal-1>=96||equal==p)return false; + char key[16]={},value[96]={};memcpy(key,p,equal-p);memcpy(value,equal+1,end-equal-1); + uint16_t f=field(key); + // No pin selection, permission/stop changes, or persistent writes over DM. + // Unknown or duplicated fields reject the whole notification string. + if(!f||f==FGpio||f==FRemote||f==FStop||(next.fields&f)||!strcmp(value,"inherit")||!setField(next,f,value))return false; + p=*end?end+1:end; + if(*end&&!*p)return false; + } + if(!(next.fields&(FVibe|FSound|FLed|FScreen)))return false; + if(timestamp) { + SeenRemote& seen=remote_seen[remote_seen_next++%8]; + memcpy(seen.key,contact,32);seen.timestamp=timestamp;seen.hash=hash; + } + stop();playing=next;started=now;cycles=0;running=true;remote_playing=true;remote_started=now; + if(playing.fields&FLed)sink.pulse(Led,false,-1); + if(playing.fields&FVibe)sink.pulse(Vibration,false,-1); + startCycle();return true; +} + +bool Controller::handle(const char* command,char* reply,size_t size,uint32_t now,bool connected) { + bool set=!strncmp(command,"set notify.",11),get=!strncmp(command,"get notify.",11); + bool test=!strncmp(command,"notify.test ",12),del=!strncmp(command,"notify.delete ",14); + if(!strcmp(command,"notify.stop")) { stop();snprintf(reply,size,"OK - alerts stopped");return true; } + if(!strcmp(command,"get notify")||!strcmp(command,"get notify.status")) { + snprintf(reply,size,"> enabled=%s outputs=%u supported=%u active=%s rules=%u", + prefs.enabled?"on":"off",prefs.outputs,sink.capabilities(),running?"on":"off",MAX_RULES);return true; + } + if(!strcmp(command,"get notify.gpio.pins")) { + size_t n=snprintf(reply,size,"> GPIOs:"); + for(unsigned pin=0;pin<64&&n slots:"); + for(unsigned i=0;i=sizeof(key)) { snprintf(reply,size,"Error: unknown notification setting");return true; } + memcpy(key,p,len);p+=len; + if(*p==' ')++p; + if(!strcmp(key,"enabled")||(!*p&&get)||(set&&(!strcmp(p,"on")||!strcmp(p,"off")))) { + uint16_t f=field(key); + if(strcmp(key,"enabled") && (f==0||f>FGpio)) { snprintf(reply,size,"Error: unknown notification setting");return true; } + if(get) { snprintf(reply,size,"> %s",(!strcmp(key,"enabled")?prefs.enabled:bool(prefs.outputs&f))?"on":"off");return true; } + if(strcmp(p,"on")&&strcmp(p,"off")) { snprintf(reply,size,"Error: use on|off");return true; } + bool on=!strcmp(p,"on"); + if(on&&f&&!(sink.capabilities()&f)) { snprintf(reply,size,"Error: output unsupported on this build");return true; } + uint8_t enabled=prefs.enabled,outputs=prefs.outputs; + if(!strcmp(key,"enabled"))prefs.enabled=on; + else { prefs.enabled=1;if(on)prefs.outputs|=f;else prefs.outputs&=~f; } + if(!store.save(prefs)) { prefs.enabled=enabled;prefs.outputs=outputs;snprintf(reply,size,"Error: save failed"); } + else { stop();snprintf(reply,size,"OK"); } + return true; + } + } + size_t len=strcspn(p," "); + if(!len||len>=sizeof(target)) { snprintf(reply,size,"Error: invalid target");return true; } + memcpy(target,p,len);p+=len; + if(*p==' ')value=p+1; + Selector selector; + if(!parseSelector(target,selector)) { snprintf(reply,size,"Error: use all|contact:64hex|room:64hex|channel:32hex, optional @connected|@disconnected");return true; } + if(test) { + if(value) { snprintf(reply,size,"Error: unexpected argument");return true; } + bool state=selector.when==Any?connected:selector.when==Online; + bool matched=message(selector.kind==Contact?selector.key:nullptr,selector.kind==Channel?selector.key:nullptr,state,now); + snprintf(reply,size,"%s",matched?"OK - alert test started":"Error: no enabled matching rule");return true; + } + int slot=-1,empty=-1; + for(unsigned i=0;i=0&&!sink.gpioAvailable(candidate.pin)) { + snprintf(reply,size,"Error: GPIO reserved or unavailable; use get notify.gpio.pins");return true; + } + uint8_t enabled=prefs.enabled; + prefs.rules[slot]=candidate;prefs.enabled=1; + if(!store.save(prefs)) { prefs.rules[slot]=previous;prefs.enabled=enabled;snprintf(reply,size,"Error: save failed"); } + else { stop();snprintf(reply,size,"OK"); } + return true; +} + +} } diff --git a/src/helpers/CompanionNotificationPolicy.h b/src/helpers/CompanionNotificationPolicy.h new file mode 100644 index 000000000..881c004a3 --- /dev/null +++ b/src/helpers/CompanionNotificationPolicy.h @@ -0,0 +1,294 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace mesh { namespace notify { + +constexpr unsigned MAX_RULES = 12, MAX_PULSES = 12, MAX_SOUND = 64; +enum Output : uint8_t { Vibration = 1, Sound = 2, Led = 4, Screen = 8, Gpio = 16 }; +enum Stop : uint8_t { Button, Connected, Never }; +enum When : uint8_t { Any, Online, Offline }; +enum Kind : uint8_t { All, Contact, Channel }; +enum Field : uint16_t { FVibe=1, FSound=2, FLed=4, FScreen=8, FGpio=16, + FRepeat=32, FGap=64, FStop=128, FRemote=256 }; +struct Pulse { + uint16_t ms[MAX_PULSES] = {}; + uint8_t count = 0; + uint32_t duration() const { uint32_t n=0; for(unsigned i=0;i'9') return false; + n=0; + do { unsigned digit=*p++-'0'; if(digit>maximum || n>(maximum-digit)/10) return false; n=n*10+digit; } + while(*p>='0'&&*p<='9'); + return n<=maximum; +} +inline bool unsignedValue(const char* p,uint32_t& n,uint32_t maximum) { + return number(p,n,maximum)&&!*p; +} +inline bool parsePulse(const char* p,Pulse& out) { + Pulse next; + if(!strcmp(p,"off")) { out=next; return true; } + do { + uint32_t n; + if(next.count==MAX_PULSES || !number(p,n,60000) || !n) return false; + next.ms[next.count++]=n; + if(!*p) { out=next; return true; } + if(*p++!=',' || !*p) return false; + } while(true); +} +inline bool durationDenominator(uint32_t n) { return n==1||n==2||n==4||n==8||n==16||n==32; } +// Strict subset matching NonBlockingRTTTL's monophonic parser and C4..B7 table. +// In particular octave 3, B#7, malformed defaults, and zero BPM are unsafe. +inline bool soundDuration(const char* p,uint32_t& total) { + total=0; + if(!*p || !strcmp(p,"off")) return true; + unsigned name=0; + while(*p && *p!=':') { if(!((*p>='a'&&*p<='z')||(*p>='A'&&*p<='Z')||(*p>='0'&&*p<='9')||*p=='_')||++name>12) return false; ++p; } + if(!name||*p++!=':'||strncmp(p,"d=",2)) return false; + p+=2; uint32_t d,o,b; + if(!number(p,d,32)||!durationDenominator(d)||*p++!=','||strncmp(p,"o=",2)) return false; + p+=2; + if(!number(p,o,7)||o<4||*p++!=','||strncmp(p,"b=",2)) return false; + p+=2; + if(!number(p,b,900)||b<25||*p++!=':'||!*p) return false; + do { + uint32_t nd=d,no=o; + if(*p>='0'&&*p<='9' && (!number(p,nd,32)||!durationDenominator(nd))) return false; + char note=*p++; + if((note<'a'||note>'g')&¬e!='p') return false; + if(*p=='#') { if(note!='a'&¬e!='c'&¬e!='d'&¬e!='f'&¬e!='g') return false; ++p; } + bool dot=*p=='.'; if(dot) ++p; + if(*p>='0'&&*p<='9' && (!number(p,no,7)||no<4)) return false; + if(*p=='.') { if(dot) return false; dot=true; ++p; } + uint32_t ms=(60000/b)*4/nd; if(dot) ms+=ms/2; + total+=ms; + if(total>60000) return false; + if(!*p) return true; + if(*p++!=','||!*p) return false; + } while(true); +} +inline int hex(char c) { + if(c>='0'&&c<='9')return c-'0'; if(c>='a'&&c<='f')return c-'a'+10; + if(c>='A'&&c<='F')return c-'A'+10; return -1; +} +inline bool parseSelector(const char* text,Selector& out) { + Selector next; const char* suffix=strchr(text,'@'); + size_t len=suffix?size_t(suffix-text):strlen(text); + if(suffix) { + if(!strcmp(suffix,"@connected"))next.when=Online; + else if(!strcmp(suffix,"@disconnected"))next.when=Offline; + else return false; + } + if(len==3&&!strncmp(text,"all",3)) { out=next; return true; } + unsigned bytes; + if(len==72&&!strncmp(text,"contact:",8)) { next.kind=Contact; text+=8; bytes=32; } + else if(len==69&&!strncmp(text,"room:",5)) { next.kind=Contact; text+=5; bytes=32; } + else if(len==40&&!strncmp(text,"channel:",8)) { next.kind=Channel; text+=8; bytes=16; } + else return false; + for(unsigned i=0;i=MAX_SOUND||!soundDuration(value,total))return false; + strcpy(rule.sound,!strcmp(value,"off")?"":value); + } else if(f==FScreen) { + if(!strcmp(value,"on"))rule.screen=1;else if(!strcmp(value,"off"))rule.screen=0;else return false; + } else if(f==FGpio) { + if(!strcmp(value,"off")) { rule.pin=-1;rule.gpio=Pulse(); } + else { + const char* p=value; + if(!number(p,n,63)||*p++!=':'||!parsePulse(p,rule.gpio))return false; + rule.pin=n; + } + } else if(f==FRepeat) { + if(!strcmp(value,"forever"))rule.repeat=0; + else { if(!unsignedValue(value,n,65535)||!n)return false;rule.repeat=n; } + } else if(f==FGap) { + if(!unsignedValue(value,n,60000)||!n)return false;rule.gap=n; + } else if(f==FStop) { + if(!strcmp(value,"button"))rule.stop=Button; + else if(!strcmp(value,"connected"))rule.stop=Connected; + else if(!strcmp(value,"never"))rule.stop=Never; + else return false; + } else if(f==FRemote) { + if(rule.selector.kind!=Contact||rule.selector.when!=Any)return false; + if(!strcmp(value,"on"))rule.remote=1;else if(!strcmp(value,"off"))rule.remote=0;else return false; + } else return false; + rule.fields |= f;return true; +} +inline void overlay(Rule& dst,const Rule& src) { + if(src.fields&FVibe)dst.vibration=src.vibration; + if(src.fields&FSound)memcpy(dst.sound,src.sound,sizeof(dst.sound)); + if(src.fields&FLed)dst.led=src.led; + if(src.fields&FScreen)dst.screen=src.screen; + if(src.fields&FGpio) { dst.gpio=src.gpio;dst.pin=src.pin; } + if(src.fields&FRepeat)dst.repeat=src.repeat; + if(src.fields&FGap)dst.gap=src.gap; + if(src.fields&FStop)dst.stop=src.stop; + dst.fields|=src.fields; +} + +class Sink { +public: + virtual ~Sink() = default; + virtual uint8_t capabilities() const = 0; + virtual bool gpioAvailable(uint8_t pin) const = 0; + virtual void pulse(Output output,bool on,int8_t pin) = 0; + virtual void melody(const char* rtttl) = 0; // nullptr stops playback + virtual void screen(int8_t mode) = 0; // -1 restores display policy +}; +class Store { +public: + virtual ~Store() = default; + virtual bool save(const Settings& settings) = 0; +}; +class Controller { + Sink& sink; Store& store; + Settings prefs; + Rule playing; + uint32_t started=0,cycle_ms=0; + uint16_t cycles=0; + uint8_t levels=0; + bool running=false,was_connected=false,connection_initialized=false; + bool remote_playing=false; + uint32_t remote_started=0; + struct SeenRemote { uint8_t key[32]={};uint32_t timestamp=0,hash=0; } remote_seen[8]; + uint8_t remote_seen_next=0; + void drive(uint32_t elapsed) { + const Output outputs[]={Vibration,Led,Gpio}; + const Pulse* patterns[]={&playing.vibration,&playing.led,&playing.gpio}; + for(unsigned i=0;i<3;++i) { + bool on=(prefs.outputs&outputs[i])&&patterns[i]->level(elapsed); + if(on!=bool(levels&outputs[i])) { + sink.pulse(outputs[i],on,playing.pin); + if(on)levels|=outputs[i];else levels&=~outputs[i]; + } + } + } + void startCycle(uint32_t elapsed=0) { + uint32_t sound_ms=0;soundDuration(playing.sound,sound_ms); + cycle_ms=playing.vibration.duration(); + if(playing.led.duration()>cycle_ms)cycle_ms=playing.led.duration(); + if(playing.gpio.duration()>cycle_ms)cycle_ms=playing.gpio.duration(); + if(sound_ms>cycle_ms)cycle_ms=sound_ms; + if(!cycle_ms)cycle_ms=1000; // screen-only alert + drive(elapsed); + if(elapsed=0)sink.screen(playing.screen); + } +public: + Controller(Sink& output,Store& storage):sink(output),store(storage) {} + Settings& settings() { return prefs; } + const Settings& settings() const { return prefs; } + bool active() const { return running; } + bool ownsLed() const { return running&&(playing.fields&FLed); } + bool overridesScreen() const { return running&&(!(prefs.outputs&Screen)||(playing.fields&FScreen)); } + void stop() { + if(!running)return; + sink.melody(nullptr); + sink.pulse(Vibration,false,-1);sink.pulse(Led,false,-1); + if(playing.pin>=0)sink.pulse(Gpio,false,playing.pin); + sink.screen(-1);levels=0;running=false;remote_playing=false; + } + bool button() { if(!running||playing.stop!=Button)return false;stop();return true; } + void loop(uint32_t now,bool connected) { + if(remote_playing&&uint32_t(now-remote_started)>=15000)stop(); + if(running && playing.stop==Connected && connection_initialized && connected&&!was_connected)stop(); + was_connected=connected;connection_initialized=true; + if(!running)return; + uint32_t elapsed=now-started; + // Late loops skip missed pulses rather than blocking or stretching them. + uint32_t period=cycle_ms+playing.gap; + uint32_t advances=elapsed/period; + if(advances) { + if(playing.repeat && advances>=uint32_t(playing.repeat-cycles)) { stop();return; } + cycles+=advances;started+=advances*period;elapsed=now-started; + sink.melody(nullptr);startCycle(elapsed); + } + if(playing.repeat && cycles+1>=playing.repeat && elapsed>=cycle_ms) { stop();return; } + drive(elapsed); + } + bool message(const uint8_t* contact,const uint8_t* channel,bool connected,uint32_t now) { + if(!prefs.enabled)return false; + Rule next;bool matched=false; + for(unsigned rank=0;rank<4;++rank)for(const Rule& r:prefs.rules) { + if(!r.used||!(r.fields&255)||unsigned((r.selector.kind!=All?2:0)+(r.selector.when!=Any?1:0))!=rank)continue; + if((r.selector.when==Online&&!connected)||(r.selector.when==Offline&&connected))continue; + if(r.selector.kind==Contact&&(!contact||memcmp(r.selector.key,contact,32)))continue; + if(r.selector.kind==Channel&&(!channel||memcmp(r.selector.key,channel,16)))continue; + overlay(next,r);matched=true; + } + if(!matched)return false; + stop();playing=next;started=now;cycles=0;running=true; + if(playing.fields&FLed)sink.pulse(Led,false,-1); + if(playing.fields&FVibe)sink.pulse(Vibration,false,-1); + if(playing.pin>=0)sink.pulse(Gpio,false,playing.pin); + startCycle();return true; + } + bool handle(const char* command,char* reply,size_t capacity,uint32_t now,bool connected); + bool validSettings(const Settings& candidate) const; + bool remoteMessage(const uint8_t* contact,const char* text,uint32_t now,uint32_t timestamp=0); +}; + +} } diff --git a/src/helpers/ui/DRV2605Vibration.cpp b/src/helpers/ui/DRV2605Vibration.cpp index 3a42bb884..9a782faf2 100644 --- a/src/helpers/ui/DRV2605Vibration.cpp +++ b/src/helpers/ui/DRV2605Vibration.cpp @@ -26,6 +26,7 @@ void DRV2605Vibration::trigger(bool force) { unsigned long now = millis(); if (!force && _last_trigger != 0 && now - _last_trigger < VIBRATION_TIMEOUT) return; _last_trigger = now; + drv.setMode(DRV2605_MODE_INTTRIG); drv.setWaveform(0, DRV2605_EFFECT); drv.setWaveform(1, 0); // pause drv.setWaveform(2, DRV2605_EFFECT); @@ -44,4 +45,10 @@ void DRV2605Vibration::stop() { if (_ready) drv.stop(); } +void DRV2605Vibration::pulse(bool on) { + if (!_ready) return; + drv.setMode(DRV2605_MODE_REALTIME); + drv.setRealtimeValue(on ? 100 : 0); +} + #endif // ifdef HAS_DRV2605 diff --git a/src/helpers/ui/DRV2605Vibration.h b/src/helpers/ui/DRV2605Vibration.h index a40894be1..85e630700 100644 --- a/src/helpers/ui/DRV2605Vibration.h +++ b/src/helpers/ui/DRV2605Vibration.h @@ -26,6 +26,7 @@ public: void loop(); // no-op, the DRV2605 plays effects autonomously bool isVibrating(); void stop(); // stop vibration immediately + void pulse(bool on); // continuous RTP amplitude for scheduled ms pulses void quiet(bool q) { _quiet = q; } bool isQuiet() const { return _quiet; } diff --git a/src/helpers/ui/DisplayDriver.cpp b/src/helpers/ui/DisplayDriver.cpp index a746f99e2..353cbbcef 100644 --- a/src/helpers/ui/DisplayDriver.cpp +++ b/src/helpers/ui/DisplayDriver.cpp @@ -3,12 +3,14 @@ bool DisplayDriver::servicePower(bool usb_power, bool app_connected, bool pairing) { _power_policy.update(mesh::ui::displayPowerPrefs(), usb_power, app_connected, pairing, millis()); - const bool changed = isOn() != _power_policy.on(); - if (changed) { if (_power_policy.on()) turnOn(); else turnOff(); } + const bool desired = _notification_power < 0 ? _power_policy.on() : _notification_power != 0; + const bool changed = isOn() != desired; + if (changed) { if (desired) turnOn(); else turnOff(); } return changed; } bool DisplayDriver::wake(mesh::ui::DisplayWake reason) { + if (_notification_power >= 0) return false; if (!_power_policy.wake(reason, millis())) return false; if (_power_policy.on() && !isOn()) turnOn(); return true; diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index b8fc0074f..9d779afc1 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -17,6 +17,7 @@ public: class DisplayDriver { int _w, _h; mesh::ui::DisplayPowerPolicy _power_policy; + int8_t _notification_power = -1; protected: DisplayDriver(int w, int h) { _w = w; _h = h; } void setDimensions(int w, int h) { _w = w; _h = h; } @@ -58,6 +59,11 @@ public: bool servicePower(bool usb_power, bool app_connected = false, bool pairing = false); bool wake(mesh::ui::DisplayWake reason); void dismiss(); + void notificationPower(int8_t mode) { + _notification_power = mode; + if (mode == 1) turnOn(); + else if (mode == 0) turnOff(); + } virtual void clear() = 0; virtual void startFrame(ColorVal bkg = UIColor::window_bkg) = 0; virtual void setTextSize(int sz) = 0; diff --git a/src/helpers/ui/buzzer.cpp b/src/helpers/ui/buzzer.cpp index dde59f5d4..2a714be79 100644 --- a/src/helpers/ui/buzzer.cpp +++ b/src/helpers/ui/buzzer.cpp @@ -28,6 +28,21 @@ void genericBuzzer::play(const char *melody) { // Serial.println(isQuiet()); } +void genericBuzzer::playNotification(const char* melody) { + stop(); +#ifdef PIN_BUZZER_EN + digitalWrite(PIN_BUZZER_EN, HIGH); +#endif + rtttl::begin(PIN_BUZZER, melody); +} + +void genericBuzzer::stop() { + rtttl::stop(); +#ifdef PIN_BUZZER_EN + if (_is_quiet) digitalWrite(PIN_BUZZER_EN, LOW); +#endif +} + bool genericBuzzer::isPlaying() { return rtttl::isPlaying(); } @@ -59,4 +74,4 @@ bool genericBuzzer::isQuiet() { return _is_quiet; } -#endif // ifdef PIN_BUZZER \ No newline at end of file +#endif // ifdef PIN_BUZZER diff --git a/src/helpers/ui/buzzer.h b/src/helpers/ui/buzzer.h index 53edda857..be701d2ac 100644 --- a/src/helpers/ui/buzzer.h +++ b/src/helpers/ui/buzzer.h @@ -21,6 +21,8 @@ class genericBuzzer public: void begin(); // set up buzzer port void play(const char *melody); // Generic play function + void playNotification(const char* melody); + void stop(); void loop(); // loop driven-nonblocking void startup(); // play startup sound void shutdown(); // play shutdown sound diff --git a/test/test_companion_notifications/test_companion_notifications.cpp b/test/test_companion_notifications/test_companion_notifications.cpp new file mode 100644 index 000000000..c93768530 --- /dev/null +++ b/test/test_companion_notifications/test_companion_notifications.cpp @@ -0,0 +1,178 @@ +#include +#include +#include +#include + +using namespace mesh::notify; +struct OutputSink : Sink { + bool vibration=false,led=false,gpio=false; + int8_t screen_state=-1; + std::string tune; + uint8_t supported=31; + uint8_t capabilities() const override { return supported; } + bool gpioAvailable(uint8_t pin) const override { return pin==22; } + void pulse(Output o,bool on,int8_t) override { + if(o==Vibration)vibration=on;else if(o==Led)led=on;else if(o==Gpio)gpio=on; + } + void melody(const char* text) override { tune=text?text:""; } + void screen(int8_t mode) override { screen_state=mode; } +}; +struct MemoryStore : Store { + Settings saved;bool succeeds=true;unsigned writes=0; + bool save(const Settings& s) override { ++writes;if(!succeeds)return false;saved=s;return true; } +}; +class Notifications : public ::testing::Test { +protected: + OutputSink sink;MemoryStore store;Controller c{sink,store};char reply[160]={}; + std::string cmd(const char* command,uint32_t now=0,bool connected=false) { + EXPECT_TRUE(c.handle(command,reply,sizeof(reply),now,connected));return reply; + } +}; + +TEST(NotificationParsing, MillisecondPulseSequenceAndStrictBounds) { + Pulse p;ASSERT_TRUE(parsePulse("50,300,40,20,500",p));EXPECT_EQ(910U,p.duration()); + EXPECT_TRUE(p.level(0));EXPECT_TRUE(p.level(49));EXPECT_FALSE(p.level(50)); + EXPECT_FALSE(p.level(349));EXPECT_TRUE(p.level(350));EXPECT_FALSE(p.level(390)); + EXPECT_TRUE(p.level(410));EXPECT_FALSE(p.level(910)); + for(auto text:{"0","-1","50,","50,,20","60001","42949672950","50 20","1,1,1,1,1,1,1,1,1,1,1,1,1"})EXPECT_FALSE(parsePulse(text,p)); + ASSERT_TRUE(parsePulse("off",p));EXPECT_EQ(0,p.count); +} +TEST(NotificationParsing, ValidatesTheActualBuzzerTableAndRtttlSyntax) { + uint32_t ms; + ASSERT_TRUE(soundDuration("vip:d=8,o=5,b=120:c,c#,16p,4g.6",ms));EXPECT_GT(ms,0U); + for(auto text:{"abc","x:d=8,o=3,b=120:c","x:d=8,o=5,b=0:c","x:d=8,o=8,b=120:c", + "x:d=3,o=5,b=120:c","x:d=8,o=5,b=120:b#7","x:d=8,o=5,b=120:c,", + "x:d=8,o=5,b=120:c..","x:d=8,o=5,b=120:c8","x:d=8,o=5,b=120:p#", + "x:d=8,o=5,b=120:c+","x:d=8,o=5,b=120:c, d"})EXPECT_FALSE(soundDuration(text,ms))< GPIOs: 22",cmd("get notify.gpio.pins")); +} +TEST_F(Notifications, FailedSaveRollsBackRuleMasterAndEnableState) { + store.succeeds=false; + EXPECT_EQ("Error: save failed",cmd("set notify.led all 50"));EXPECT_FALSE(c.settings().enabled); + EXPECT_FALSE(c.settings().rules[0].used); + EXPECT_EQ("Error: save failed",cmd("set notify.sound off"));EXPECT_EQ(31,c.settings().outputs); +} +TEST_F(Notifications, PatternRestoresNormalDisplayAndDoesNotMutateDisplayPrefs) { + cmd("set notify.screen all on");c.message(nullptr,nullptr,false,0);EXPECT_EQ(1,sink.screen_state); + c.loop(1000,false);EXPECT_EQ(-1,sink.screen_state); + cmd("set notify.screen all off");c.message(nullptr,nullptr,false,0);EXPECT_EQ(0,sink.screen_state); + c.stop();EXPECT_EQ(-1,sink.screen_state); +} +TEST_F(Notifications, DeleteGetInheritanceAndExplicitTest) { + cmd("set notify.led all@disconnected 50,300,50"); + EXPECT_EQ("50,300,50",cmd("get notify.led all@disconnected")); + EXPECT_EQ("OK - alert test started",cmd("notify.test all@disconnected",0,true));EXPECT_TRUE(sink.led); + EXPECT_EQ("OK",cmd("notify.delete all@disconnected"));EXPECT_FALSE(c.active()); + EXPECT_EQ("inherit",cmd("get notify.led all@disconnected")); +} +TEST_F(Notifications, SettingsValidationRejectsCorruptionAndChangedPinOwnership) { + cmd("set notify.gpio all 22:50");ASSERT_TRUE(c.validSettings(store.saved)); + store.saved.rules[0].pin=4;EXPECT_FALSE(c.validSettings(store.saved)); + store.saved.rules[0].pin=22;store.saved.rules[0].gpio.count=255;EXPECT_FALSE(c.validSettings(store.saved)); +} +TEST_F(Notifications, FiniteRepeatEndsWithoutPlayingAnExtraCycle) { + cmd("set notify.led all 50");cmd("set notify.repeat all 2");cmd("set notify.gap all 100"); + c.message(nullptr,nullptr,false,0);c.loop(50,false);EXPECT_FALSE(sink.led);EXPECT_TRUE(c.active()); + c.loop(150,false);EXPECT_TRUE(sink.led);c.loop(200,false);EXPECT_FALSE(c.active());EXPECT_FALSE(sink.led); +} + +TEST_F(Notifications, DmRequiresAnExplicitFullContactPermissionAndNeverWritesSettings) { + uint8_t contact[32]={};memset(contact,1,sizeof(contact)); + const char* dm="!notify led=50,300,40,20,500 sound=vip:d=8,o=5,b=180:c,e,g screen=on"; + EXPECT_FALSE(c.remoteMessage(contact,dm,0)); + EXPECT_EQ("OK",cmd("set notify.remote contact:0101010101010101010101010101010101010101010101010101010101010101 on")); + unsigned writes=store.writes; + ASSERT_TRUE(c.remoteMessage(contact,dm,0));EXPECT_TRUE(sink.led);EXPECT_EQ(1,sink.screen_state); + EXPECT_EQ(writes,store.writes); + uint8_t other[32]={};EXPECT_FALSE(c.remoteMessage(other,dm,0)); + c.stop();EXPECT_FALSE(c.message(contact,nullptr,false,0)); // permission alone does not replace normal alerts + EXPECT_EQ("OK",cmd("set notify.remote contact:0101010101010101010101010101010101010101010101010101010101010101 off")); + EXPECT_FALSE(c.remoteMessage(contact,dm,0)); +} +TEST_F(Notifications, DmRejectsGpioStopPermissionAndMalformedMessagesAtomically) { + uint8_t contact[32]={};memset(contact,1,sizeof(contact)); + cmd("set notify.remote contact:0101010101010101010101010101010101010101010101010101010101010101 on"); + for(auto dm:{"!notify led=50 gpio=22:50","!notify led=50 stop=never","!notify remote=on", + "!notify led=50 led=100","!notify led=50 unknown=foo","!notify sound=unsafe", + "!notify led=inherit","!notify led=50 ","!notify repeat=forever"}) { + EXPECT_FALSE(c.remoteMessage(contact,dm,0))< builder.pulse(bad)); +assert.equal(builder.melody("vip:d=8,o=5,b=120:c,c#,16p,4g.6").length, 4); +for (const bad of ["abc", "x:d=8,o=3,b=120:c", "x:d=8,o=5,b=0:c", "x:d=3,o=5,b=120:c", + "x:d=8,o=5,b=120:b#7", "x:d=8,o=5,b=120:c,", "x:d=8,o=5,b=120:c..", "x:d=8,o=5,b=120:p#"]) + assert.throws(() => builder.melody(bad)); +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)); + const dm = builder.notificationText(complete); + assert.ok(dm.startsWith("!notify "));assert.ok(!dm.includes("gpio="));assert.ok(dm.length <= 159); +} +const vip = {...builder.EXAMPLES.vip, id: "01".repeat(32), remote: "on"}; +assert.ok(builder.commands(vip).includes("set notify.remote contact:" + vip.id + " on")); +const room = {...vip, kind: "room"}; +assert.ok(builder.commands(room).includes("set notify.remote room:" + room.id + " on")); +assert.throws(() => builder.commands({...builder.EXAMPLES.food, remote: "on"})); +assert.throws(() => builder.commands({...vip, id: "1234"})); +const encoded = builder.encodeCommand("notify.stop", "A7"); +assert.equal(encoded[0], 60);assert.equal(encoded[3], 0x42); +assert.equal(new TextDecoder().decode(encoded.slice(4)), "A7|notify.stop"); +const decoder = new builder.FrameDecoder(); +const payload = Buffer.from("A7|OK - alerts stopped"); +const framed = Uint8Array.from([62,payload.length+1,0,0x1d,...payload]); +assert.deepEqual(decoder.push(framed.slice(0, 2)), []); +assert.deepEqual(decoder.push(framed.slice(2, 8)), []); +const result = decoder.push(framed.slice(8));assert.equal(result.length, 1); +assert.equal(new TextDecoder().decode(result[0].slice(1)), "A7|OK - alerts stopped"); +assert.deepEqual(new builder.FrameDecoder().push(Uint8Array.from([62,255,255,...framed])), [result[0]]); +assert.throws(() => builder.encodeCommand("x".repeat(173), "00")); +console.log("Notification builder: pulse, melody, examples, permissions, and fragmented USB frame tests passed"); + +async function transportTests() { + let incoming, requestedSignals, opened; + const seen = []; + 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); + seen.push(command); + const frame = value => { + const body = new TextEncoder().encode(value); + return Uint8Array.from([62, body.length + 1, 0, 0x1d, ...body]); + }; + incoming.enqueue(frame("zz|ignored unrelated reply")); + const reply = frame(tag + "|OK"); + incoming.enqueue(reply.slice(0, 2));incoming.enqueue(reply.slice(2, 7));incoming.enqueue(reply.slice(7)); + }}), + async open(options) { opened = options; }, + async setSignals(signals) { requestedSignals = signals; }, + async close() {} + }; + const client = new builder.SerialClient(port); + await client.open();assert.equal(opened.baudRate, 115200);assert.equal(requestedSignals.dataTerminalReady, true); + const first = client.command("notify.test room:" + "02".repeat(32)); + await assert.rejects(client.command("notify.stop"), /Wait for the current command/); + assert.equal(await first, "OK"); + assert.equal(await client.command("notify.stop"), "OK"); + assert.equal(seen.length, 2);await client.close(); + // A failed write must reject promptly and clear the pending request. + const failed = new builder.SerialClient({ + ...port, readable: new ReadableStream(), + writable: new WritableStream({write() { throw new Error("Cable removed"); }}) + }); + await failed.open();await assert.rejects(failed.command("get notify"), /Cable removed/); + assert.equal(failed.pending, null);await failed.close(); +} +transportTests().then(() => console.log("USB transport: tagged round trips, busy rejection, disconnect failure and clean close passed"), error => { console.error(error);process.exitCode = 1; }); diff --git a/test/test_notification_settings.py b/test/test_notification_settings.py new file mode 100644 index 000000000..394408048 --- /dev/null +++ b/test/test_notification_settings.py @@ -0,0 +1,134 @@ +"""Run the production notification file format against a fault-injected filesystem.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +STUB = r'''#pragma once +#include +#include +#include +#include +#include +struct FakeFS; +struct File { + FakeFS* fs = nullptr; + std::string name; + size_t cursor = 0; + File() = default; + explicit File(FakeFS& storage):fs(&storage) {} + File(FakeFS* storage,const char* path):fs(storage),name(path) {} + explicit operator bool() const { return fs != nullptr; } + bool open(const char*,int); + size_t size() const; + size_t read(uint8_t*,size_t); + size_t write(const uint8_t*,size_t); + void close() {} +}; +struct FakeFS { + std::map> files; + bool fail_write=false,fail_publish=false; + bool exists(const char* path) { return files.count(path); } + bool remove(const char* path) { files.erase(path);return true; } + bool rename(const char* from,const char* to) { + if(fail_publish && std::string(from)=="/notify_prefs.tmp")return false; + if(!exists(from)||exists(to))return false; + files[to]=files[from];files.erase(from);return true; + } + File open(const char* path,const char* mode,bool=false) { + if(*mode=='w')files[path].clear(); + return exists(path)?File(this,path):File(); + } +}; +inline bool File::open(const char* path,int mode) { + if(mode==2)fs->files[path].clear(); + if(!fs->exists(path))return false; + name=path;cursor=0;return true; +} +inline size_t File::size() const { return fs->files.at(name).size(); } +inline size_t File::read(uint8_t* out,size_t n) { + auto& bytes=fs->files[name];n=std::min(n,bytes.size()-cursor); + memcpy(out,bytes.data()+cursor,n);cursor+=n;return n; +} +inline size_t File::write(const uint8_t* in,size_t n) { + if(fs->fail_write)return 0; + auto& bytes=fs->files[name];bytes.resize(cursor+n); + memcpy(bytes.data()+cursor,in,n);cursor+=n;return n; +} +#define FILESYSTEM FakeFS +#define FILE_O_READ 1 +#define FILE_O_WRITE 2 +''' +PROGRAM = r''' +#include +#include "NotificationSettingsFile.h" +using namespace mesh::notify; +int main() { + FakeFS fs;Settings prefs,loaded; + assert(!readSettings(nullptr,"/notify_prefs",loaded)); + assert(!writeSettings(nullptr,prefs)); + assert(!readSettings(&fs,"/notify_prefs",loaded)); + prefs.enabled=1;prefs.outputs=7; + auto& rule=prefs.rules[0];rule.used=1; + assert(parseSelector(("room:"+std::string(64,'a')).c_str(),rule.selector)); + assert(setField(rule,FRemote,"on")); + assert(setField(rule,FVibe,"50,300,40,20,500")); + assert(writeSettings(&fs,prefs)); + assert(readSettings(&fs,"/notify_prefs",loaded)); + assert(!memcmp(&prefs,&loaded,sizeof(prefs))); + const auto original=fs.files.at("/notify_prefs"); + prefs.outputs=3; + fs.fail_write=true; + assert(!writeSettings(&fs,prefs)); + assert(fs.files.at("/notify_prefs")==original); + fs.fail_write=false;fs.fail_publish=true; + assert(!writeSettings(&fs,prefs)); + assert(readSettings(&fs,"/notify_prefs",loaded)); + assert(loaded.outputs==7); + // Preserve the recovery copy if the primary is already damaged. + fs.files["/notify_prefs.bak"]=original; + fs.files["/notify_prefs"][8]^=1; + assert(!readSettings(&fs,"/notify_prefs",loaded)); + assert(!writeSettings(&fs,prefs)); + assert(readSettings(&fs,"/notify_prefs",loaded)); + assert(loaded.outputs==7); + fs.fail_publish=false; + assert(writeSettings(&fs,prefs)); + assert(readSettings(&fs,"/notify_prefs",loaded)); + assert(loaded.outputs==3); + const auto good=fs.files["/notify_prefs"]; + for(size_t index:{size_t(0),size_t(4),size_t(8),good.size()-1}) { + fs.files["/notify_prefs"]=good;fs.files["/notify_prefs"][index]^=1; + assert(!readSettings(&fs,"/notify_prefs",loaded)); + } + fs.files["/notify_prefs"]=good;fs.files["/notify_prefs"].pop_back(); + assert(!readSettings(&fs,"/notify_prefs",loaded)); + fs.files["/notify_prefs"]=good;fs.files["/notify_prefs"].push_back(0); + assert(!readSettings(&fs,"/notify_prefs",loaded)); + // Interrupted publication can load its good backup, then save again. + fs.files.erase("/notify_prefs");fs.files["/notify_prefs.bak"]=good; + assert(readSettings(&fs,"/notify_prefs.bak",loaded)); + assert(writeSettings(&fs,loaded)); + assert(fs.files["/notify_prefs"]==good); +} +''' + +class NotificationSettingsTests(unittest.TestCase): + def test_roundtrip_corruption_and_failed_publication_on_each_fs_api(self): + with tempfile.TemporaryDirectory() as folder: + folder=Path(folder) + (folder/"helpers").mkdir() + (folder/"helpers/IdentityStore.h").write_text(STUB) + (folder/"test.cpp").write_text(PROGRAM) + for platform in ("ESP32_PLATFORM", "NRF52_PLATFORM", "STM32_PLATFORM"): + with self.subTest(platform=platform): + binary=folder/platform + subprocess.run(["g++","-std=c++17","-Wall","-Wextra","-Werror", + "-Wno-misleading-indentation","-D"+platform,"-I"+str(folder), + "-I"+str(ROOT/"src"),"-I"+str(ROOT/"examples/companion_radio"), + str(folder/"test.cpp"),"-o",str(binary)],check=True) + subprocess.run([str(binary)],check=True) + +if __name__=="__main__": + unittest.main() diff --git a/variants/lilygo_techo_card/platformio.ini b/variants/lilygo_techo_card/platformio.ini index 599191e53..7d5861af7 100644 --- a/variants/lilygo_techo_card/platformio.ini +++ b/variants/lilygo_techo_card/platformio.ini @@ -70,6 +70,7 @@ build_flags = ${LilyGo_T-Echo_Card.build_flags} -I src/helpers/ui -I examples/companion_radio/ui-tiny + -D MESH_COMPANION_SCREEN_STARTUP_BYTES=1024 -D PIN_BUZZER=38 -D QSPIFLASH=1 -D MAX_CONTACTS=350 @@ -100,6 +101,7 @@ build_flags = ${LilyGo_T-Echo_Card.build_flags} -I src/helpers/ui -I examples/companion_radio/ui-tiny + -D MESH_COMPANION_SCREEN_STARTUP_BYTES=1024 -D PIN_BUZZER=38 -D QSPIFLASH=1 -D MAX_CONTACTS=350