Add programmable Companion alerts and trusted DM and room notifications

This commit is contained in:
mikecarper committed 2026-09-30 03:44:22 -07:00
1 parent 062a835f09
commit 2a31b20cb8
33 files changed
+1846 -24

No files matched your search

+2
View File
@@ -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
+250
View File
@@ -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);
@@ -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; } }
+5
View File
@@ -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:
+5
View File
@@ -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.
+1
View File
@@ -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)
+237
View File
@@ -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.
<div class="notification-builder" data-notification-builder>
<div class="notification-examples">
<button type="button" data-example="find">Find my node</button>
<button type="button" data-example="food">Food order ready</button>
<button type="button" data-example="vip">VIP chat</button>
</div>
<form data-role="form" onsubmit="return false">
<section class="notification-card">
<h2>Who should trigger the alert?</h2>
<div class="notification-grid">
<label>Messages from<select name="kind"><option value="all">All contacts and channels</option><option value="contact">One contact</option><option value="room">One room server</option><option value="channel">One channel</option></select></label>
<label>Contact/room key or channel slot<input name="id" autocomplete="off" placeholder="Full contact/room public key, or channel slot such as 3"></label>
<label>Companion client status<select name="when"><option value="any">Connected or disconnected</option><option value="connected">Connected</option><option value="disconnected">Disconnected</option></select></label>
</div>
<p>Connected means an active phone/app, USB, WiFi, Ethernet or serial
companion client. It selects a message rule; connecting or disconnecting
alone does not create an alert. Contact matching uses the full public key.
Channel slots resolve to the channel's key when the rule is saved.</p>
</section>
<section class="notification-card">
<h2>What should the device do?</h2>
<div class="notification-grid">
<label>Vibration pattern<input name="vibration" value="50,300,40,20,500" spellcheck="false"><small>Millisecond on/off durations, or off / inherit</small></label>
<label>LED pattern<input name="led" value="100,100,100,500" spellcheck="false"><small>Starts on, alternates off/on, finishes off</small></label>
<label class="notification-wide">Sound melody<input name="sound" value="order:d=8,o=5,b=180:c,e,g,4c6" spellcheck="false"><small>RTTTL: note letters a-g, # for sharp, p for rest; off / inherit also work</small></label>
<label>Screen<select name="screen"><option value="inherit">Use normal screen policy</option><option value="on">On during alert</option><option value="off">Off during alert</option></select></label>
<label>GPIO output<input name="gpio" value="off" spellcheck="false"><small>off / inherit, or an approved pin and pattern: 22:50,300,50</small></label>
</div>
<p><code>50,300,40,20,500</code> means on for 50 ms, off for 300 ms,
on for 40 ms, off for 20 ms, then on for 500 ms. A rule controls only
outputs physically supported by its firmware. The USB connection reports
the supported outputs and approved GPIO pins before applying anything.</p>
</section>
<section class="notification-card">
<h2>Repeating and stopping</h2>
<div class="notification-grid">
<label>Repeat count<input name="repeat" value="1"><small>A number, or forever</small></label>
<label>Gap between cycles (ms)<input name="gap" value="500" inputmode="numeric"></label>
<label>Stop alert<select name="stop"><option value="button">Button</option><option value="connected">Node connected</option><option value="never">Never automatically dismiss</option></select></label>
</div>
<p>Node connected stops an active alert when a companion client connects.
Never disables button/connection dismissal; a finite repeat count still
finishes. <code>notify.stop</code> always stops playback. A new matching
message replaces the current alert.</p>
</section>
<section class="notification-card">
<h2>Allow notification strings from this contact or room</h2>
<label>Permission<select name="remote"><option value="inherit">Leave permission unchanged</option><option value="on">Allow this contact or room</option><option value="off">Revoke this contact or room</option></select></label>
<p>Only applies to a contact or room server's full public key, without a connection
condition. An allowed contact can DM the notification string below, or
it can be posted in an allowed room. Trusting a room allows its posters
to send alerts through that server.
GPIO, stop-policy and permission changes are forbidden in DMs or room posts. The device
stops playback after at most 15 seconds, including infinite repeats.
Incoming strings do not save settings and obey the recipient's master switches.</p>
</section>
</form>
<p data-role="error" class="notification-error" role="alert" aria-live="polite"></p>
<section class="notification-card">
<h2>Preview one cycle</h2>
<div class="notification-indicators"><span data-indicator="vibration" data-on="false">Vibration</span><span data-indicator="led" data-on="false">LED</span><span data-indicator="gpio" data-on="false">GPIO</span><span data-indicator="screen" data-on="false">Screen</span></div>
<p data-role="preview-time" aria-live="off">Ready</p>
<button type="button" data-action="preview">Preview with sound</button>
<button type="button" data-action="preview-stop">Stop preview</button>
<p>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.</p>
</section>
<section class="notification-card">
<h2>Device commands</h2>
<pre><code data-role="commands"></code></pre>
<button type="button" data-action="copy">Copy commands</button><span data-role="copy-status" aria-live="polite"></span>
<h3>Notification DM or room post</h3>
<pre><code data-role="dm"></code></pre>
<p>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.</p>
</section>
<section class="notification-card">
<h2>Try it on your device</h2>
<p data-role="device-status" aria-live="polite">No device connected</p>
<div class="notification-buttons">
<button type="button" data-action="connect">Connect USB</button>
<button type="button" data-action="disconnect">Disconnect USB</button>
<button type="button" data-action="apply">Save rule</button>
<button type="button" data-action="apply-test">Save and test</button>
<button type="button" data-action="test">Test saved rule</button>
<button type="button" data-action="stop">Stop device alert</button>
<button type="button" data-action="delete">Delete selected rule</button>
</div>
<p>Save writes the generated commands in order and stops at the first device
error; earlier successful settings remain saved. A test with a connection
suffix selects that profile even over USB. Stop-on-connect still watches
real subsequent connections. USB testing requires Chrome or Edge.</p>
<pre data-role="device-log" class="notification-log" aria-live="polite"></pre>
</section>
</div>
## 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.<output>` and `set notify.<output> 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:<slot>`. 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.
+17 -1
View File
@@ -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(); }
@@ -2,6 +2,16 @@
#include <helpers/CompanionReaderConfig.h>
#include <helpers/ota/OtaMemoryPolicy.h>
// 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) \
+65 -4
View File
@@ -1,4 +1,5 @@
#include <helpers/ui/DisplayPowerSettings.h>
#include "NotificationSettingsFile.h"
#include "MyMesh.h"
#include <helpers/CompanionTxRoutingCLI.h>
#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.<output> <all|contact:key|room:key|channel:slot> <pattern>\r\n");
terminalOutput().print(" set notify.remote <contact:key|room:key> on|off (!notify, max 15s, no GPIO)\r\n");
terminalOutput().print(" notify.test <target> / notify.stop / notify.delete <target>\r\n");
#endif
terminalOutput().print(" get display.inbox\r\n");
terminalOutput().print(" set display.inbox <history|pending|unread>\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()
+22
View File
@@ -26,6 +26,7 @@
#include "DataStore.h"
#include <helpers/RadioProfileCLI.h>
#include <helpers/CompanionNotificationPolicy.h>
#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;
@@ -0,0 +1,62 @@
#pragma once
#include <helpers/IdentityStore.h>
#include <helpers/CompanionNotificationPolicy.h>
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<const uint8_t*>(&prefs);
for(size_t i=0;i<sizeof(prefs);++i)h=(h^p[i])*16777619UL;
return h;
}
inline bool readSettings(FILESYSTEM* fs,const char* path,Settings& prefs) {
if(!fs)return false;
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
File file(*fs);if(!file.open(path,FILE_O_READ))return false;
#else
File file=fs->open(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<uint8_t*>(&prefs),sizeof(prefs))==sizeof(prefs)
&&file.read(reinterpret_cast<uint8_t*>(&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<const uint8_t*>(&prefs),sizeof(prefs))==sizeof(prefs)
&&file.write(reinterpret_cast<uint8_t*>(&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");
} }
@@ -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
+40 -4
View File
@@ -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
}
+10 -2
View File
@@ -18,7 +18,9 @@
#ifdef PIN_BUZZER
#include <helpers/ui/buzzer.h>
#endif
#ifdef PIN_VIBRATION
#ifdef HAS_DRV2605
#include <helpers/ui/DRV2605Vibration.h>
#elif defined(PIN_VIBRATION)
#include <helpers/ui/GenericVibration.h>
#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);
+46 -3
View File
@@ -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
}
@@ -16,6 +16,8 @@
#ifdef HAS_DRV2605
#include <helpers/ui/DRV2605Vibration.h>
#elif defined(PIN_VIBRATION)
#include <helpers/ui/GenericVibration.h>
#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);
+55 -5
View File
@@ -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
}
+10 -2
View File
@@ -18,7 +18,9 @@
#ifdef PIN_BUZZER
#include <helpers/ui/buzzer.h>
#endif
#ifdef PIN_VIBRATION
#ifdef HAS_DRV2605
#include <helpers/ui/DRV2605Vibration.h>
#elif defined(PIN_VIBRATION)
#include <helpers/ui/GenericVibration.h>
#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);
+2
View File
@@ -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
+2
View File
@@ -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>
+176
View File
@@ -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(n<size)formatPulse(r.gpio,out+n,size-n); }
} else if(f==FRepeat) { if(r.repeat)snprintf(out,size,"%u",unsigned(r.repeat));else snprintf(out,size,"forever"); }
else if(f==FGap)snprintf(out,size,"%u",unsigned(r.gap));
else if(f==FStop)snprintf(out,size,"%s",r.stop==Button?"button":r.stop==Connected?"connected":"never");
else if(f==FRemote)snprintf(out,size,"%s",r.remote?"on":"off");
}
}
bool Controller::validSettings(const Settings& s) const {
if(s.enabled>1||(s.outputs&~31))return false;
for(unsigned i=0;i<MAX_RULES;++i) {
const Rule& r=s.rules[i];
if(r.used>1)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;j<p->count;++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<i;++j)if(s.rules[j].used&&s.rules[j].selector==r.selector)return false;
}
return true;
}
bool Controller::remoteMessage(const uint8_t* contact,const char* text,uint32_t now,uint32_t timestamp) {
if(!prefs.enabled||!contact||!text||strncmp(text,"!notify ",8))return false;
bool allowed=false;
for(const Rule& r:prefs.rules)if(r.used&&(r.fields&FRemote)&&r.remote
&&r.selector.kind==Contact&&r.selector.when==Any&&!memcmp(r.selector.key,contact,32))allowed=true;
if(!allowed)return false;
uint32_t hash=2166136261UL;
for(const char* ch=text;*ch;++ch)hash=(hash^uint8_t(*ch))*16777619UL;
// Signed room delivery and DM retries must not restart the same 15s alert.
if(timestamp)for(const SeenRemote& seen:remote_seen)
if(seen.timestamp==timestamp&&seen.hash==hash&&!memcmp(seen.key,contact,32))return true;
Rule next;
const char* p=text+8;
while(*p) {
const char* end=strchr(p,' ');if(!end)end=p+strlen(p);
const char* equal=static_cast<const char*>(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<size;++pin)if(sink.gpioAvailable(pin))n+=snprintf(reply+n,size-n," %u",pin);
return true;
}
if(!strncmp(command,"get notify.rules",16)) {
const char* p=command+16;
if(!*p) {
size_t n=snprintf(reply,size,"> slots:");
for(unsigned i=0;i<MAX_RULES&&n<size;++i)if(prefs.rules[i].used)n+=snprintf(reply+n,size-n," %u",i);
return true;
}
uint32_t index;
if(*p!=' '||!unsignedValue(p+1,index,MAX_RULES-1)||!prefs.rules[index].used)snprintf(reply,size,"Error: rule slot not found");
else { formatSelector(prefs.rules[index].selector,reply,size); }
return true;
}
if(!set&&!get&&!test&&!del)return false;
char key[16]={},target[90]={};const char* value=nullptr;
const char* p=command+(test?12:del?14:11);
if(set||get) {
size_t len=strcspn(p," ");
if(!len||len>=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<MAX_RULES;++i) {
if(prefs.rules[i].used&&prefs.rules[i].selector==selector)slot=i;
if(!prefs.rules[i].used&&empty<0)empty=i;
}
if(del) {
if(value||slot<0) { snprintf(reply,size,"Error: rule not found");return true; }
Rule previous=prefs.rules[slot];prefs.rules[slot]=Rule();
if(!store.save(prefs)) { prefs.rules[slot]=previous;snprintf(reply,size,"Error: save failed"); }
else { stop();snprintf(reply,size,"OK"); }return true;
}
uint16_t f=field(key);
if(!f) { snprintf(reply,size,"Error: unknown notification setting");return true; }
if(get) {
if(value)snprintf(reply,size,"Error: unexpected argument");
else valueText(slot<0?Rule():prefs.rules[slot],f,reply,size);
return true;
}
if(!value||!*value) { snprintf(reply,size,"Error: missing value");return true; }
if(slot<0)slot=empty;
if(slot<0) { snprintf(reply,size,"Error: notification rule limit reached");return true; }
Rule previous=prefs.rules[slot],candidate=previous;
if(!candidate.used) { candidate=Rule();candidate.used=1;candidate.selector=selector; }
if(!setField(candidate,f,value)) { snprintf(reply,size,"Error: invalid notification value");return true; }
if(f<=FGpio && strcmp(value,"off") && strcmp(value,"inherit") && !(sink.capabilities()&f)) {
snprintf(reply,size,"Error: output unsupported on this build");return true;
}
if(f==FGpio&&candidate.pin>=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;
}
} }
+294
View File
@@ -0,0 +1,294 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
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<count;++i) n+=ms[i]; return n; }
bool level(uint32_t elapsed) const {
for(unsigned i=0;i<count;++i) { if(elapsed<ms[i]) return !(i&1); elapsed-=ms[i]; }
return false;
}
};
struct Selector {
uint8_t key[32] = {};
Kind kind = All;
When when = Any;
bool operator==(const Selector& other) const {
return kind==other.kind && when==other.when && !memcmp(key,other.key,sizeof(key));
}
};
struct Rule {
Selector selector;
Pulse vibration, led, gpio;
char sound[MAX_SOUND] = {};
uint16_t fields = 0, repeat = 1, gap = 500;
int8_t pin = -1;
int8_t screen = -1; // -1: normal display policy, 0: off, 1: on during alert
Stop stop = Button;
uint8_t used = 0;
uint8_t remote = 0; // contact-only permission, never a wildcard
};
struct Settings {
Rule rules[MAX_RULES];
uint8_t enabled = 0, outputs = 31;
};
inline bool number(const char*& p, uint32_t& n, uint32_t maximum) {
if(*p<'0'||*p>'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')&&note!='p') return false;
if(*p=='#') { if(note!='a'&&note!='c'&&note!='d'&&note!='f'&&note!='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<bytes;++i) {
int a=hex(text[i*2]),b=hex(text[i*2+1]); if(a<0||b<0)return false;
next.key[i]=(a<<4)|b;
}
out=next; return true;
}
inline void formatSelector(const Selector& s,char* out,size_t capacity) {
const char* kind=s.kind==Contact?"contact:":s.kind==Channel?"channel:":"all";
size_t n=snprintf(out,capacity,"%s",kind);
unsigned bytes=s.kind==Contact?32:s.kind==Channel?16:0;
for(unsigned i=0;i<bytes&&n+2<capacity;++i)n+=snprintf(out+n,capacity-n,"%02x",s.key[i]);
if(n<capacity)snprintf(out+n,capacity-n,"%s",s.when==Online?"@connected":s.when==Offline?"@disconnected":"");
}
inline void formatPulse(const Pulse& p,char* out,size_t capacity) {
if(!p.count) { snprintf(out,capacity,"off");return; }
size_t n=0;for(unsigned i=0;i<p.count&&n<capacity;++i)n+=snprintf(out+n,capacity-n,"%s%u",i?",":"",unsigned(p.ms[i]));
}
inline uint16_t field(const char* text) {
if(!strcmp(text,"vibration"))return FVibe; if(!strcmp(text,"sound"))return FSound;
if(!strcmp(text,"led"))return FLed; if(!strcmp(text,"screen"))return FScreen;
if(!strcmp(text,"gpio"))return FGpio; if(!strcmp(text,"repeat"))return FRepeat;
if(!strcmp(text,"gap"))return FGap; if(!strcmp(text,"stop"))return FStop;
if(!strcmp(text,"remote"))return FRemote;return 0;
}
inline bool setField(Rule& rule,uint16_t f,const char* value) {
if(!strcmp(value,"inherit")) { rule.fields &= ~f; return true; }
uint32_t n;
if(f==FVibe) { if(!parsePulse(value,rule.vibration))return false; }
else if(f==FLed) { if(!parsePulse(value,rule.led))return false; }
else if(f==FSound) {
uint32_t total;
if(strlen(value)>=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<sound_ms&&(prefs.outputs&Sound)&&playing.sound[0])sink.melody(playing.sound);
if(!(prefs.outputs&Screen))sink.screen(0);
else if(playing.screen>=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);
};
} }
+7
View File
@@ -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
+1
View File
@@ -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; }
+4 -2
View File
@@ -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;
+6
View File
@@ -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;
+16 -1
View File
@@ -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
#endif // ifdef PIN_BUZZER
+2
View File
@@ -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
@@ -0,0 +1,178 @@
#include <gtest/gtest.h>
#include <helpers/CompanionNotificationPolicy.h>
#include <vector>
#include <string>
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))<<text;
}
TEST_F(Notifications, ExecutesExactBoundaryPulsesAndReturnsOutputsOff) {
EXPECT_EQ("OK",cmd("set notify.led all 50,300,40,20,500"));
c.loop(0,false);ASSERT_TRUE(c.message(nullptr,nullptr,false,0));EXPECT_TRUE(sink.led);
c.loop(50,false);EXPECT_FALSE(sink.led);c.loop(350,false);EXPECT_TRUE(sink.led);
c.loop(390,false);EXPECT_FALSE(sink.led);c.loop(410,false);EXPECT_TRUE(sink.led);
c.loop(910,false);EXPECT_FALSE(c.active());EXPECT_FALSE(sink.led);
}
TEST_F(Notifications, ContactAndChannelOverrideEachOutputAndConditionIndependently) {
EXPECT_EQ("OK",cmd("set notify.led all 100"));
EXPECT_EQ("OK",cmd("set notify.sound all@connected ping:d=8,o=5,b=120:c"));
const char* key="contact:0101010101010101010101010101010101010101010101010101010101010101";
std::string command=std::string("set notify.led ")+key+"@connected off";
EXPECT_EQ("OK",cmd(command.c_str()));
uint8_t contact[32];memset(contact,1,sizeof(contact));
c.message(contact,nullptr,true,0);EXPECT_FALSE(sink.led);EXPECT_FALSE(sink.tune.empty());
c.message(contact,nullptr,false,0);EXPECT_TRUE(sink.led);EXPECT_TRUE(sink.tune.empty());
cmd("set notify.vibration channel:02020202020202020202020202020202 50,20,50");
uint8_t channel[16];memset(channel,2,sizeof(channel));
c.message(nullptr,channel,false,0);EXPECT_TRUE(sink.led);EXPECT_TRUE(sink.vibration);
}
TEST_F(Notifications, ButtonConnectedAndNeverStopPolicies) {
cmd("set notify.led all 50");cmd("set notify.repeat all forever");
c.loop(0,false);c.message(nullptr,nullptr,false,0);EXPECT_TRUE(c.button());EXPECT_FALSE(c.active());
cmd("set notify.stop all connected");c.message(nullptr,nullptr,false,0);
EXPECT_FALSE(c.button());c.loop(10,true);EXPECT_FALSE(c.active());
cmd("set notify.stop all never");c.message(nullptr,nullptr,true,20);
c.loop(30,false);c.loop(40,true);EXPECT_TRUE(c.active());EXPECT_FALSE(c.button());
EXPECT_EQ("OK - alerts stopped",cmd("notify.stop"));EXPECT_FALSE(c.active());
}
TEST_F(Notifications, ForeverPulsesAcrossMillisRolloverAndSkipsMissedWindows) {
cmd("set notify.led all 50,300,40,20,500");cmd("set notify.repeat all forever");
uint32_t start=UINT32_MAX-20;c.message(nullptr,nullptr,false,start);
c.loop(start+50,false);EXPECT_FALSE(sink.led);
c.loop(start+350,false);EXPECT_TRUE(sink.led);
c.loop(start+1410+60,false);EXPECT_FALSE(sink.led);EXPECT_TRUE(c.active());
}
TEST_F(Notifications, GpioUsesTheRepeaterBoardApprovalAndUnsupportedOutputsFail) {
EXPECT_EQ("Error: GPIO reserved or unavailable; use get notify.gpio.pins",cmd("set notify.gpio all 4:50"));
EXPECT_EQ("OK",cmd("set notify.gpio all 22:50,300,50"));
c.message(nullptr,nullptr,false,0);EXPECT_TRUE(sink.gpio);c.loop(50,false);EXPECT_FALSE(sink.gpio);
sink.supported=Led;EXPECT_EQ("Error: output unsupported on this build",cmd("set notify.vibration all 50"));
EXPECT_EQ("Error: output unsupported on this build",cmd("set notify.sound on"));
EXPECT_EQ("> 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))<<dm;EXPECT_FALSE(c.active());EXPECT_FALSE(sink.gpio);
}
EXPECT_EQ("Error: invalid notification value",cmd("set notify.remote all on"));
EXPECT_EQ("Error: invalid notification value",cmd("set notify.remote contact:0101010101010101010101010101010101010101010101010101010101010101@connected on"));
}
TEST_F(Notifications, RemoteRepeatingAndLongPatternsHaveAHard15SecondLimitIncludingRollover) {
uint8_t contact[32]={};memset(contact,1,sizeof(contact));
cmd("set notify.remote contact:0101010101010101010101010101010101010101010101010101010101010101 on");
uint32_t started=UINT32_MAX-100;
ASSERT_TRUE(c.remoteMessage(contact,"!notify led=60000 repeat=forever screen=on",started));
c.loop(started+14999,false);EXPECT_TRUE(c.active());EXPECT_TRUE(sink.led);
c.loop(started+15000,false);EXPECT_FALSE(c.active());EXPECT_FALSE(sink.led);EXPECT_EQ(-1,sink.screen_state);
}
TEST_F(Notifications, RoomPermissionUsesTheFullRoomIdentityAndRetryDoesNotExtendTheDeadline) {
uint8_t room[32];memset(room,2,sizeof(room));
cmd("set notify.remote room:0202020202020202020202020202020202020202020202020202020202020202 on");
const char* post="!notify led=60000 repeat=forever";
ASSERT_TRUE(c.remoteMessage(room,post,0,100));
ASSERT_TRUE(c.remoteMessage(room,post,14000,100));
c.loop(15000,false);EXPECT_FALSE(c.active());EXPECT_FALSE(sink.led);
ASSERT_TRUE(c.remoteMessage(room,post,16000,100));EXPECT_FALSE(c.active());
uint8_t author[32];memset(author,3,sizeof(author));
EXPECT_FALSE(c.remoteMessage(author,post,16000,100));
ASSERT_TRUE(c.remoteMessage(room,post,16000,101));EXPECT_TRUE(c.active());
}
TEST_F(Notifications, IncomingRoomNotificationHonorsRecipientMastersAndButtonDismissal) {
uint8_t room[32];memset(room,2,sizeof(room));
cmd("set notify.remote room:0202020202020202020202020202020202020202020202020202020202020202 on");
cmd("set notify.led off");cmd("set notify.vibration off");cmd("set notify.sound off");
ASSERT_TRUE(c.remoteMessage(room,"!notify led=1000 vibration=1000 sound=vip:d=8,o=5,b=120:c repeat=forever",0,101));
EXPECT_FALSE(sink.led);EXPECT_FALSE(sink.vibration);EXPECT_TRUE(sink.tune.empty());
EXPECT_TRUE(c.button());EXPECT_FALSE(c.active());
cmd("set notify.enabled off");
EXPECT_FALSE(c.remoteMessage(room,"!notify led=1000",10,102));
}
int main(int argc,char** argv) { ::testing::InitGoogleTest(&argc,argv);return RUN_ALL_TESTS(); }
+77
View File
@@ -0,0 +1,77 @@
"use strict";
const assert = require("node:assert/strict");
const builder = require("../docs/_javascript/notification_builder.js");
assert.deepEqual(builder.pulse("50,300,40,20,500"), [50,300,40,20,500]);
for (const [time, on] of [[0,true],[49,true],[50,false],[349,false],[350,true],[390,false],[410,true],[910,false]])
assert.equal(builder.level(builder.pulse("50,300,40,20,500"), time), on);
for (const bad of ["0", "50,", "50,,20", "60001", "-5", "50 20", "1,1,1,1,1,1,1,1,1,1,1,1,1"])
assert.throws(() => 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; });
+134
View File
@@ -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 <map>
#include <string>
#include <vector>
#include <cstring>
#include <cstdint>
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<std::string,std::vector<uint8_t>> 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 <cassert>
#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()
@@ -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