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.
+
+
+
+ Find my node
+ Food order ready
+ VIP chat
+
+
+
+
+ Preview one cycle
+ Vibration LED GPIO Screen
+ Ready
+ Preview with sound
+ Stop preview
+ 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
+
+ Copy 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
+
+ Connect USB
+ Disconnect USB
+ Save rule
+ Save and test
+ Test saved rule
+ Stop device alert
+ Delete selected rule
+
+ 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