Harden USB logging and persist watchdog events in management reports

This commit is contained in:
mikecarper committed 2026-10-03 15:29:19 -07:00
1 parent c1a533572a
commit 6febd15ded
98 files changed
+7207 -372

No files matched your search

+12 -1
View File
@@ -111,6 +111,7 @@ jobs:
python3 -B test/test_companion_prefs_transactions.py -v
python3 -B test/test_companion_primary_radio_persistence.py -v
python3 -B test/test_companion_response_bounds.py -v
python3 -B test/test_companion_identity_cli.py -v
python3 -B test/test_companion_uncached_storage.py -v
python3 -B test/test_companion_ota_config.py -v
python3 -B test/test_ota_identity_policy.py -v
@@ -237,8 +238,18 @@ jobs:
test_indicator_display_profile test_indicator_render_profile
test_indicator_exclusive_transport test_companion_transport_selector
- name: Verify logging sleep guards and instructions
- name: Verify independent USB packet/debug logging, recovery, and instructions
run: |
python3 -B test/test_common_usb_debug.py
python3 -B test/test_companion_usb_logging.py
python3 -B test/test_usb_logging_watchdog.py
python3 -B test/test_usb_watchdog_event.py
python3 -B test/test_usb_logging_client_activity.py
python3 -B test/test_usb_logging_watchdog_runtime.py
python3 -B test/test_usb_logging_watchdog_roles.py
python3 -B test/test_nrf52_usb_console.py
python3 -B test/test_esp32_tinyusb_nonblocking.py
python3 -B test/test_esp32_tinyusb_role_hygiene.py
python3 -B test/test_logging_sleep_contract.py
python3 -B test/test_picker_controls_generator.py
python3 -B test/test_firmware_picker_package.py
+121 -20
View File
@@ -7,9 +7,13 @@
// which Web Crypto exposes directly. AES-CBC with a zero IV supplies the
// required AES block primitive; only its first ciphertext block is used.
const HEADER = 83;
const MGR2_HEADER = 98;
const CURRENT_HEADER = 111;
const TAG = 16;
const ENTRY = 13;
const PER_PAGE = 6;
const MGR2_PER_PAGE = 5;
const CURRENT_PER_PAGE = 4;
const MAX_KEYS = 36;
const ZERO_BLOCK = new Uint8Array(16);
const UTF8 = new TextEncoder();
@@ -214,22 +218,35 @@
return (await hmacSha256(key, concatBytes(radio, normalized))).slice(0, 12);
}
function layout(payload) {
const magic = bytesToAscii(payload, 0, 4);
if (magic === "MGR1") return { header: HEADER, perPage: PER_PAGE };
if (magic === "MGR2") return { header: MGR2_HEADER, perPage: MGR2_PER_PAGE };
if (magic === "MGR3") return { header: CURRENT_HEADER, perPage: CURRENT_PER_PAGE };
return null;
}
function canonicalLength(payload) {
if (payload.length < HEADER + TAG || bytesToAscii(payload, 0, 4) !== "MGR1") {
managementError("This data does not begin with an MGR1 management page.");
const format = layout(payload);
if (!format || payload.length < format.header + TAG) {
managementError("This data does not begin with a complete MGR1/MGR2/MGR3 management page.");
}
if (format.header >= MGR2_HEADER && ((uint16LE(payload, 83) & 0xf800) || (payload[85] & 0xc0))) {
managementError("Reserved USB status bits must be zero.");
}
if (format.header === CURRENT_HEADER) usbWatchdogEvent(payload);
const page = payload[78];
const pages = payload[79];
const total = payload[80];
const first = payload[81];
const count = payload[82];
const expectedPages = total ? Math.ceil(total / PER_PAGE) : 1;
const expectedPages = total ? Math.ceil(total / format.perPage) : 1;
if (total > MAX_KEYS || pages !== expectedPages || page >= pages ||
first !== page * PER_PAGE || first > total ||
count !== Math.min(PER_PAGE, total - first)) {
managementError("The MGR1 page index or ACL bounds are invalid.");
first !== page * format.perPage || first > total ||
count !== Math.min(format.perPage, total - first)) {
managementError("The management page index or ACL bounds are invalid.");
}
return HEADER + count * ENTRY + TAG;
return format.header + count * ENTRY + TAG;
}
function paddedFloodLength(canonical) {
@@ -256,13 +273,13 @@
const hops = pathInfo & 0x3f;
if (width > 3 || hops * width > 64) return null;
const payloadOffset = pathOffset + 1 + width * hops;
if (payloadOffset >= bytes.length || bytesToAscii(bytes, payloadOffset, 4) !== "MGR1") return null;
if (payloadOffset >= bytes.length || !layout(bytes.slice(payloadOffset))) return null;
const payload = bytes.slice(payloadOffset);
const canonical = canonicalLength(payload);
const padded = paddedFloodLength(canonical);
if (payload.length !== canonical &&
(payload.length !== padded || !payload.slice(canonical).every((value) => value === 0))) {
managementError("MGR1 packet padding or length is invalid.");
managementError("Management packet padding or length is invalid.");
}
return {
payload: payload.slice(0, canonical),
@@ -277,26 +294,35 @@
}
function parseCanonical(bytes) {
if (bytesToAscii(bytes, 0, 4) !== "MGR1") return null;
if (!layout(bytes)) return null;
const canonical = canonicalLength(bytes);
const padded = paddedFloodLength(canonical);
if (bytes.length !== canonical &&
(bytes.length !== padded || !bytes.slice(canonical).every((value) => value === 0))) {
managementError("MGR1 payload padding or length is invalid.");
managementError("Management payload padding or length is invalid.");
}
return { payload: bytes.slice(0, canonical), envelope: null };
}
function inputByteStreams(input) {
if (typeof input !== "string" || input.trim() === "") {
managementError("Paste one or more MGR1 payloads or GroupData packet hex values first.");
managementError("Paste one or more MGR1/MGR2/MGR3 payloads or GroupData packet hex values first.");
}
if (input.length > 32768) managementError("The pasted value is too large to be management-report data.");
const candidates = [input, ...input.split(/\r?\n/)];
// A wrapped single page is one candidate, not an invalid first-line
// fragment. Conversely, joining complete page lines creates a bogus
// oversized canonical page: let each line retain its own bounds.
try {
const whole = hexToBytes(input);
if (parseCanonical(whole) || parsePacket(whole)) return [whole];
} catch (_error) {
// JSON/log wrappers and multiple complete page lines are handled below.
}
const candidates = input.split(/\r?\n/);
const quotedRaw = /["'](?:raw|data)["']\s*:\s*["']([^"']+)["']/gi;
let match;
while ((match = quotedRaw.exec(input)) !== null) candidates.push(match[1]);
const streams = input.match(/(?:0x)?[0-9a-f]{2}(?:(?:[\s:,_-]*)(?:0x)?[0-9a-f]{2}){15,}/gi);
const streams = input.match(/(?:0x)?[0-9a-f]{2}(?:(?:[ \t:,_-]*)(?:0x)?[0-9a-f]{2}){15,}/gi);
if (streams) candidates.push(...streams);
const unique = new Map();
@@ -319,7 +345,7 @@
try {
parsed = parseCanonical(bytes) || parsePacket(bytes);
} catch (error) {
if (error instanceof ManagementDecodeError && bytesToAscii(bytes, 0, 4) !== "MGR1") {
if (error instanceof ManagementDecodeError && !layout(bytes)) {
// It may be an unrelated hex stream alongside a valid packet.
continue;
}
@@ -333,7 +359,7 @@
}
}
if (!found.length) {
managementError("No complete MGR1 management page was found. Paste canonical MGR1 payload hex or an entire GroupData packet.");
managementError("No complete management page was found. Paste canonical MGR1/MGR2/MGR3 payload hex or an entire GroupData packet.");
}
return found;
}
@@ -366,6 +392,45 @@
return ({ 1: "repeater", 2: "room server", 3: "sensor" })[value] || `unknown (${value})`;
}
function usbStatus(payload) {
if (!["MGR2", "MGR3"].includes(bytesToAscii(payload, 0, 4))) return null;
const flags = uint16LE(payload, 83);
const names = ["supported", "loggingEnabled", "watchdogEnabled", "hostConnected",
"readerConnected", "stalled", "recovering", "recoveryDeferred", "persistenceReady", "watchdogAuto", "loggerActive"];
const result = {};
names.forEach((name, bit) => { result[name] = Boolean(flags & (1 << bit)); });
result.stage = payload[85] & 3;
result.backoffStep = (payload[85] >> 2) & 15;
result.retrySeconds = uint32LE(payload, 86);
result.inactiveSeconds = uint32LE(payload, 90);
result.autoConnectedSeconds = uint32LE(payload, 94);
return result;
}
function usbWatchdogEvent(payload) {
if (bytesToAscii(payload, 0, 4) !== "MGR3") return null;
const code = payload[98];
const sequence = uint32LE(payload, 107);
if (!sequence) {
if (!payload.slice(98, 111).every((value) => value === 0)) {
managementError("Invalid empty USB watchdog event.");
}
return null;
}
const reasons = code & 15;
const actionCode = (code >> 4) & 7;
if (!reasons || actionCode < 1 || actionCode > 4) {
managementError("Invalid USB watchdog event reason/action.");
}
const names = ["host-absent", "reader-absent", "tx-stalled", "client-inactive"];
return {
reasons, reasonNames: names.filter((_name, bit) => reasons & (1 << bit)),
actionCode, action: [null, "soft-recovery", "reenumerate", "reboot-requested", "reboot-cancelled"][actionCode],
persisted: Boolean(code & 128), epoch: uint32LE(payload, 99),
uptimeSeconds: uint32LE(payload, 103), sequence,
};
}
function publicFields(payload, envelope) {
const valid = uint16LE(payload, 76);
const capabilities = payload[73];
@@ -374,6 +439,7 @@
const knownActive = featureNames(active & known);
const unknownActive = featureNames(capabilities & ~known);
return {
protocol: bytesToAscii(payload, 0, 4),
radioId: bytesToHex(payload.slice(4, 20)),
sequence: uint32LE(payload, 20),
timestamp: uint32LE(payload, 24),
@@ -396,6 +462,8 @@
acl: `${payload[80]} total; ${payload[82]} on this page`,
partialSince: Boolean(valid & 32),
mcuTemperature: Boolean(valid & 64),
usbLogging: usbStatus(payload),
usbWatchdogLast: usbWatchdogEvent(payload),
envelope,
};
}
@@ -417,9 +485,11 @@
});
const sorted = [...byPage.values()].sort((left, right) => left.payload[78] - right.payload[78]);
const reference = sorted[0].payload;
const header = layout(reference).header;
if (sorted.some((page) => !equalBytes(page.payload.slice(0, 78), reference.slice(0, 78)) ||
!equalBytes(page.payload.slice(HEADER, header), reference.slice(HEADER, header)) ||
page.payload[79] !== reference[79] || page.payload[80] !== reference[80])) {
managementError("MGR1 pages do not belong to the same snapshot.");
managementError("Management pages do not belong to the same snapshot.");
}
return sorted;
}
@@ -427,9 +497,10 @@
async function decryptPage(payload, password, candidate) {
const root = await passwordKey(password);
const key = await deriveKey(root, "MeshCore-MGR1-SIV", payload.slice(4, 20));
const header = layout(payload).header;
const privateLength = payload[82] * ENTRY;
const plaintext = await openSiv(key, payload.slice(0, HEADER),
payload.slice(HEADER, HEADER + privateLength), payload.slice(HEADER + privateLength));
const plaintext = await openSiv(key, payload.slice(0, header),
payload.slice(header, header + privateLength), payload.slice(header + privateLength));
try {
let wanted = null;
if (candidate) wanted = await aclFingerprint(password, payload.slice(4, 20), candidate);
@@ -517,6 +588,7 @@
function render(model) {
const values = [
["Protocol", model.public.protocol],
["Reporter", model.public.radioId], ["Sequence", model.public.sequence],
["Report time", timestampText(model.public.timestamp)], ["Role", model.public.role],
["Firmware", model.public.firmware], ["Bootloader", model.public.bootloader],
@@ -528,6 +600,35 @@
["Compiled capabilities", model.public.compiled], ["Known active capabilities", model.public.active],
["Page", model.public.page], ["ACL", model.public.acl],
];
const usb = model.public.usbLogging;
if (usb) {
const stage = ["idle", "soft recovery wait", "re-enumeration wait", "reboot pending"][usb.stage];
values.push(["USB logging", usb.supported ? (usb.loggingEnabled ? "enabled" : "disabled") : "unsupported"],
["USB watchdog", usb.watchdogAuto ? "Auto (qualifying)" : usb.watchdogEnabled ? "On" : "Off"],
["USB Auto qualification", `${usb.autoConnectedSeconds}s of 1209600s`],
["USB host / reader", `${usb.hostConnected ? "host connected" : "no host"}; ${usb.readerConnected ? "reader connected" : "no reader"}`],
["USB logging client", usb.loggerActive ? "recent USB stats polling (15-minute lease)" : "inactive (stats lease or healthy USB link missing)"],
["USB recovery", `${usb.stalled ? "stalled; " : ""}${usb.recovering ? "recovering; " : ""}${usb.recoveryDeferred ? "deferred; " : ""}${stage}; backoff=${usb.backoffStep}`],
["USB retry interval / inactive", `${usb.retrySeconds}s / ${usb.inactiveSeconds}s`],
["USB persistence", usb.persistenceReady ? "ready" : "not ready"]);
} else {
values.push(["USB logging status", "unavailable in MGR1"]);
}
const event = model.public.usbWatchdogLast;
if (event) {
values.push(["Last USB watchdog event", `#${event.sequence}: ${event.action}`],
["USB watchdog event reasons", event.reasonNames.join(", ")],
["USB watchdog event time", `${timestampText(event.epoch)} (advisory RTC; raw epoch=${event.epoch})`],
["USB watchdog event uptime", `${event.uptimeSeconds}s at the recorded boot`],
["USB watchdog event persistence", event.persisted ? "saved" : "RAM-only"],
["USB watchdog action meaning", event.actionCode === 3
? "Reboot was requested; this does not prove a physical reset occurred."
: event.actionCode === 4 ? "Reboot request was cancelled; no physical reset is claimed."
: "Recovery attempt was recorded; this does not prove successful recovery."]);
} else {
values.push(["Last USB watchdog event", model.public.protocol === "MGR3"
? "none recorded" : "unavailable in MGR1/MGR2"]);
}
summary.replaceChildren();
values.forEach(([term, description]) => {
const item = document.createElement("div");
@@ -540,7 +641,7 @@
});
status.textContent = model.authenticated
? `Password-authenticated ${model.suppliedPages}/${model.pageCount} page${model.pageCount === 1 ? "" : "s"}.`
: "Public-only decode — no authenticity claim without the management password.";
: "Public-only decode - no authenticity claim without the management password.";
status.classList.toggle("management-status-authenticated", model.authenticated);
warningList.replaceChildren();
+9 -5
View File
@@ -104,15 +104,15 @@ getters; relaying a command over LoRa keeps its remote restrictions.
| Build/profile | Command availability |
|---|---|
| Standard non-MQTT repeater or room server | Keeps the normal role CLI and, where USB is a safe plaintext console, embeds debug/packet logging behind persistent `get/set usb.logging`. The explicitly selected portable policy can omit WebConfig and browser WiFi OTA, so those commands are unavailable and the omission is recorded in the capability manifest. |
| Standard non-MQTT repeater or room server | Keeps the normal role CLI and, where USB is a safe plaintext console, embeds packet logging behind persistent `get/set usb.logging` and separately saved `get/set usb.debug` verbosity. USB debug defaults off and requires compiled diagnostic support. The explicitly selected portable policy can omit WebConfig and browser WiFi OTA, so those commands are unavailable and the omission is recorded in the capability manifest. |
| Legacy standard logging | No longer emitted separately. Its behavior is compiled into the ordinary artifact. Size-constrained STM32 targets embed packet logging without verbose `MESH_DEBUG`. |
| LoRa-OTA (`-ota-`) | LoRa OTA adds the `ota ...` commands; it does not otherwise reduce the role CLI. ESP32 `no_external_sensors` artifacts retain the compact browser WiFi uploader, the complete CLI, and up to 254 neighbors, subject to recorded internal-DRAM reductions. |
| nRF52 repeater/room-server/sensor release pair | `full-ota` retains the complete supported sensor recipe; `reduced-ota` omits declared optional sensor drivers. Both include verified LoRa OTA, use the same logical target identity and exact storage/layout contract within the pair, and retain the role's normal commands. Repeaters/room servers also verify `retry.preset`. RAK3401 and RAK4631 reduced builds retain GPS where compatible and INA219/INA226/INA260/INA3221 I2C voltage/current monitors. External QSPI/SD targets also publish both choices. Companion/Terminal Chat and KISS roles do not enter this policy. |
| ESP32 MQTT observer or ESP-NOW bridge | Always uses the expanded FULL partition profile. The build never substitutes a reduced CLI to fit the legacy application slot. |
| FULL ESP32 USB + WiFi | Uses the matching MQTT target with packet logging on, verbose debug off, and the complete command surface supported by that role and hardware. `get/set logging.output off\|usb\|wifi\|both` selects and persists the active output paths. |
| FULL ESP32 logging fallback | Uses the matching non-MQTT target only when no WiFi MQTT sibling exists, with debug and packet logging enabled and the complete command surface supported by that role and hardware. Its persistent USB gate also covers output-off operation, avoiding a second FULL ESP-NOW image. |
| nRF52 dual-CDC Full Companion | Fresh installs expose only interface `00`; it starts as an ASCII terminal and automatically hands a complete `<` frame to framed Companion. The same interface also carries exclusive serial mOTA traffic. Enabling logging and rebooting adds interface `02` for plaintext logs. BLE and source-only LoRa OTA remain available. `get/set usb.logging` persistently controls whether the logging interface is present. |
| ESP32 single-TTY Full Companion | Every ESP32 Full image starts with the ASCII terminal on its one USB TTY and automatically hands a complete `<` frame to framed Companion. On 1.17.1.5, run `set powersaving off` first. `set usb.logging on` switches that TTY to an input-capable plaintext logging terminal and makes framed Companion unavailable on USB; `set usb.logging off` stops logging but leaves the TTY in normal ASCII mode. The terminal stop token or a valid incoming framed probe then performs the ordinary switch to Binary Companion. A saved logging-on setting starts directly in that logging terminal and disables automatic frame detection. BLE, WiFi, and source-only LoRa OTA remain available. ESP32 Full uses Arduino-ESP32 2.x where supported; RC32 and ESP32-C6 keep their board-required Arduino 3.x platform but still expose only one TTY. |
| FULL ESP32 USB + WiFi | Uses the matching MQTT target with packet logging, USB debug defaulting off, and the complete command surface supported by that role and hardware. Debug verbosity can be enabled only for diagnostics compiled into that image. `get/set logging.output off\|usb\|wifi\|both` selects and persists the active output paths; `get/set usb.debug` separately saves USB verbosity. |
| FULL ESP32 logging fallback | Uses the matching non-MQTT target only when no WiFi MQTT sibling exists, with debug and packet support compiled and the complete command surface supported by that role and hardware. USB debug defaults off; `get/set usb.debug` selects verbose output independently of packet capture. Its persistent USB master also covers output-off operation, avoiding a second FULL ESP-NOW image. |
| nRF52 dual-CDC Full Companion | Fresh installs expose only interface `00`; it starts as an ASCII terminal and automatically hands a complete `<` frame to framed Companion. The same interface also carries exclusive serial mOTA traffic. Enabling logging and rebooting adds output-only interface `02` for ASCII RAW/RX/TX records and optional debug. BLE and source-only LoRa OTA remain available. `get/set usb.logging` persistently controls whether the logging interface is present; `get/set usb.debug` changes verbosity without a reboot. Send CLI/control queries to interface `00`, not the log endpoint. |
| ESP32 single-TTY Full Companion | Every ESP32 Full image starts with the ASCII terminal on its one USB TTY and automatically hands a complete `<` frame to framed Companion. On 1.17.1.5, run `set powersaving off` first. `set usb.logging on` switches that TTY to an input-capable plaintext RAW/RX/TX logging terminal and makes framed Companion unavailable on USB; `set usb.logging off` stops logging but leaves the TTY in normal ASCII mode. `get/set usb.debug` separately controls saved verbose diagnostics, defaulting off. The terminal stop token or a valid incoming framed probe then performs the ordinary switch to Binary Companion. A saved logging-on setting starts directly in that logging terminal and disables automatic frame detection. BLE, WiFi, and source-only LoRa OTA remain available. ESP32 Full uses Arduino-ESP32 2.x where supported; RC32 and ESP32-C6 keep their board-required Arduino 3.x platform but still expose only one TTY. |
| `no_external_sensors` | Trims selected optional environmental/ranging drivers and their settings; it does not remove generic I2C, core repeater discovery, routing, or runtime RS-232 commands. RAK3401 and RAK4631 profiles retain the four common INA I2C voltage/current monitors. GPS-preserving RAK nRF52 OTA profiles retain their GPS commands and provider; RAK4631 defaults the bridge to UART 2 because RAK12501/L76K GPS uses UART 1. Legacy target suffixes remain stable for OTA identity compatibility. |
The four retained INA drivers are entries in the optional environmental-sensor
@@ -129,6 +129,10 @@ both devices on one bus, leave RAK12500 at `0x42`, strap INA3221 A0 to SCL for
`logging`, `OTA`, and `FULL` describe independent build features in historical
filenames. Current standard artifacts use no `-logging-` infix because their
USB logging is runtime controlled.
USB packet capture and debug verbosity share the canonical artifact, not a new
build/profile name. `usb.logging` remains the master; verbose diagnostics need
both that switch and saved `usb.debug` on. Fresh and legacy preferences default
debug to off. Setting debug on cannot restore code compiled out of an image.
## Canonical bulk-build policy
+15
View File
@@ -38,6 +38,14 @@ Build columns mean:
where there is no MQTT sibling. It has the complete parser and a saved
USB-logging on/off gate. ESP-NOW bridges use this profile.
USB packet output and verbose diagnostics are separate saved controls in
current source. Where `usb.logging` is available, `get/set usb.debug on|off`
selects debug intent without changing packet capture or adding a build variant.
Debug defaults off for fresh and older preference images; effective output
requires `usb.logging` and `usb.debug` on plus compiled diagnostic support.
See [live USB logging](cli_commands.md#control-live-usb-logging) for transport
restrictions and Full Companion RAW output.
The firmware-configured INA3221 and RAK12500 addresses are both `0x42`; they
cannot coexist on one bus at those addresses. Leave RAK12500 at `0x42`, strap
INA3221 A0 to SCL for `0x43`, and use a build with
@@ -71,6 +79,7 @@ over the normal binary USB, BLE, or TCP connection:
| `stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets` | Local terminal and binary command `0x42` |
| `erase` | Local terminal and binary command `0x42`; reboot after erasing stored settings |
| `set freq <MHz>` | Local terminal, binary command `0x42`, and authorized LoRa CLI; reboot to apply |
| [`get public.key`](cli_commands.md#view-this-nodes-public-key) | Companion text terminal and binary command `0x42`; read-only, no private-key export feature required |
| `get prv.key` | Local terminal and binary command `0x42`; requires private key export enabled |
| `get password` | Reports that Companion has no admin password; infrastructure returns its password locally |
| `get wifi.pwd`, `get mqttN.password`, `get mqttN.token` | Local terminal and binary command `0x42`; corresponding WiFi/MQTT feature required |
@@ -124,6 +133,9 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data; other roles can return empty data | Yes | Yes | Yes |
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local connection | Local | Local | Local |
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; nRF52 Full Companion can add a reboot-controlled second CDC | Yes | Yes | No |
| Logging | [`get/set usb.debug`](cli_commands.md#control-live-usb-logging) | Same USB-control availability; saved intent defaults off; actual diagnostics need compiled support and USB master on | Yes | Yes | No |
| Logging | [`get/set usb.watchdog off\|on\|auto`](usb_logging_watchdog.md) | Current source; recovery requires native USB, live logging, and durable storage; Auto qualifies from USB stats polling | Yes | Yes | Feature |
| Logging | [`get usb.watchdog.last`](usb_logging_watchdog.md#last-event-review) | Current source; latest USB watchdog event with advisory timestamp, uptime, action, reasons, and durability; native USB required | Yes | Yes | Feature |
| Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes |
| Radio | [`get tx`; `set tx <dbm>`](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes |
| Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes |
@@ -293,6 +305,9 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto
| Logging | [`log start`; `log stop`; `log erase`](cli_commands.md#logging) | Storage-backed roles retain data | Yes | Yes | Yes | Yes | Yes |
| Logging | [`log`](cli_commands.md#print-the-captured-log-to-the-serial-terminal) | Local connection | Local | Local | Local | Local | Local |
| Logging | [`get/set usb.logging`; unified FULL `get/set logging.output`](cli_commands.md#control-live-usb-logging) | Ordinary safe-USB artifacts; CommonCLI USB gate is persistent; unified ESP32 FULL selects off/USB/WiFi/both; every ESP32 Full Companion uses an input-capable single-TTY logging terminal with framed USB Companion disabled while logging | Yes | Yes | No | No | Yes |
| Logging | [`get/set usb.debug`](cli_commands.md#control-live-usb-logging) | Same USB-control availability; saved intent defaults off; actual diagnostics need compiled support and USB master on | Yes | Yes | No | No | Yes |
| Logging | [`get/set usb.watchdog off\|on\|auto`](usb_logging_watchdog.md) | Current source; recovery requires native USB, live logging, and durable storage; Auto qualifies from USB stats polling | Yes | Yes | Feature | Yes | Yes |
| Logging | [`get usb.watchdog.last`](usb_logging_watchdog.md#last-event-review) | Current source; latest USB watchdog event with advisory timestamp, uptime, action, reasons, and durability; native USB required | Yes | Yes | Feature | Yes | Yes |
| Radio | [`get radio`; `set radio ...`](cli_commands.md#view-or-change-this-nodes-radio-parameters) | All text CLI roles | Yes | Yes | Yes | Yes | Yes |
| Radio | [`get tx`; `set tx <dbm>`](cli_commands.md#view-or-change-this-nodes-transmit-power) | Board TX-power limits apply | Yes | Yes | Yes | Yes | Yes |
| Radio | [`tempradio ...`; `normalradio`](cli_commands.md#change-the-radio-parameters-for-a-set-duration) | Full parser | Yes | Yes | Yes | Yes | Yes |
+101 -19
View File
@@ -462,7 +462,7 @@ command `0x42` where the role supports the command; unavailable over LoRa.
### Remove a neighbor
**Usage:**
- `neighbor.remove <pubkey_prefix>`
- `neighbor.remove` — remove all neighbors
- `neighbor.remove` - remove all neighbors
**Parameters:**
- `pubkey_prefix`: An even-length hexadecimal prefix (up to the full 64-character
@@ -1040,17 +1040,27 @@ flip state on display-enabled observer builds.
## Logging
Builds compiled with `MESH_PACKET_LOGGING` emit one `RAW:` line for every
received radio frame. Serial output uses backpressure: if a connected host
temporarily stops reading, packet processing waits for USB transmit space
instead of silently omitting the record. A disconnected host cannot retain an
unbounded capture, so logging deployments should keep the reader attached and
draining the serial port.
Non-compact builds compiled with `MESH_PACKET_LOGGING` emit a `RAW:` line
for received radio frames that reach the raw capture hook while `usb.logging`
is on, including Full Companion. Frames rejected by the early route policy
are not exposed to raw capture.
`RAW:` contains the complete received frame as uppercase hexadecimal, not only
its decoded payload. Packet records do not require verbose USB debug output.
Keep the host reader attached and draining the serial port: capture is best
effort, not durable storage. Native-USB transports use bounded queues and drop
records rather than block radio processing when the host stops reading;
`DROP:<count>` markers report capture loss when output capacity returns.
Every valid received frame also emits the decoded RX summary, including signal,
Successfully parsed received frames also emit the decoded RX summary, including signal,
timing, hash, type, route, and payload information. Frames that cannot be
decoded still emit their `RAW:` line. Transmitted packets emit the decoded TX
summary.
decoded, or cannot obtain a packet-pool slot, can still emit their `RAW:` line.
Transmitted packets emit the decoded TX
summary. RAW and RX summary are separate records, not an atomic pair; a queue
drop or disconnect can leave a summary without its RAW bytes. USB log consumers
must not associate a stale RAW record with the next unrelated summary.
Size-constrained `MESH_PACKET_LOGGING_COMPACT` images instead use `R<hex>` and
`T<hex>` records; that compact stream is not the full ASCII RAW/summary format
expected by ordinary USB-to-MQTT parsers.
Ordinary non-OTA artifacts compile packet logging into the canonical image and
control its live USB output at runtime; no separate `-logging-` artifact is
@@ -1072,8 +1082,27 @@ set usb.logging on
set usb.logging off
set usb.logging on reboot
set usb.logging off reboot
get usb.debug
set usb.debug on
set usb.debug off
```
Current source also provides `get usb.watchdog` and saved
`set usb.watchdog off|on|auto`. Missing watchdog state defaults to Auto, which
requires 14 continuous healthy logging-client days before saving On. Existing
five-minute USB stats polls confirm recent logging-client activity; a plugged
cable/open TTY alone does not. See the [USB logging watchdog](usb_logging_watchdog.md) for staged
five-minute USB recovery, physical-fault-only MCU resets, persisted backoff,
status fields, and safe shutdown deferrals. Older release binaries need an
upgrade before these commands exist.
`get usb.watchdog.last` shows the latest USB watchdog event: recovery action,
fault-reason bitmask, advisory node-clock epoch, uptime at the event, sequence,
and whether the record passed durable readback. It survives reboot on durable
storage. `reboot-requested` records intent, not proof that a physical reset
occurred; a later safety veto records `reboot-cancelled`. See the watchdog
documentation for storage and timestamp limitations.
**ESP32 1.17.1.5 USB logging procedure:** disable device sleep before enabling
the log stream, using separate text commands:
@@ -1088,16 +1117,44 @@ saving. This works around the [released USB sleep bug](old-releases/1.17.1.5.md#
nRF52 does not need this ESP32 workaround.
These commands are compiled into ordinary USB-loggable artifacts and every
Full Companion. They control live USB debug and packet output. CommonCLI roles
save the setting in `/com_prefs`, so it survives reboot; their first boot
defaults to on. Full Companion and ordinary USB Companion start off on a fresh
installation so diagnostics cannot corrupt framed traffic.
Full Companion. `usb.logging` is the saved USB output master and packet-log
gate. `usb.debug` is a separate saved preference for verbose diagnostics;
effective USB debug output requires both switches to be on. Packet RAW and
RX/TX summaries require only the master switch. `get usb.debug` reports saved
intent even when the master is off. Turning `usb.logging` off does not erase
that intent; turning it back on restores debug output if `usb.debug` is on.
CommonCLI roles save these settings in `/com_prefs`; their fresh USB logging
default is on. Full Companion and ordinary USB Companion start with the
master off on a fresh installation so diagnostics cannot corrupt framed
traffic. Verbose USB debug defaults to off for every role, including when
loading older preferences without a debug field. Updates retain an existing
saved `usb.logging` choice. Older binaries without the new `usb.debug`
command retain their previous behavior until upgraded.
For packet-only capture, use these separate text commands:
```text
set usb.debug off
set usb.logging on
```
Use `set usb.debug on` temporarily when troubleshooting, then turn it off
again to reduce USB traffic. This setting applies immediately and needs no
USB-interface reboot. It does not select a firmware variant or enable code
omitted from the image: verbose output still requires compiled `MESH_DEBUG`
or other relevant diagnostic support. A packet-only build can retain the
preference without gaining missing debug messages.
Debug off suppresses verbose diagnostic chatter, not functional CLI replies,
USB port identity/drop markers, or retained operational warnings. Readers
must still filter non-packet lines instead of treating every line as a packet.
On Full Companion these lines belong to its text terminal, not `meshcli`'s
Binary `get/set` parameter namespace. Open interface `00`, send
`+++MESHCORE-TERM-START`, and then issue the command. Running
`meshcli ... get usb.logging` directly can instead return
`Unknown var usb.logging` because that is a different protocol operation.
`meshcli ... get usb.logging` or `get usb.debug` directly can instead return
`Unknown var ...` because that is a different protocol operation.
nRF52 Full Companion defaults logging to off and enumerates only USB interface
`00`, which carries Companion, terminal, and serial mOTA traffic. Enabling
@@ -1107,6 +1164,8 @@ the optional `reboot` argument saves the choice and reports that a reboot is
required when the USB interface count must change. The exact
`set usb.logging on reboot` and `set usb.logging off reboot` forms save the
choice, send their reply, and reboot one second later only when needed.
Interface `02` is logging-only, not an input-capable CLI. Send logging/debug
commands to interface `00`; USB debug uses the same log endpoint as packets.
On every ESP32 Full Companion, enter the USB text terminal and use
`set usb.logging on` (preceded by `set powersaving off` on 1.17.1.5) to turn that TTY into a logging-repeater-style plaintext
@@ -1123,6 +1182,20 @@ frames active on interface `00`; ESP32 resumes the ordinary ASCII/Binary
switcher after the logging terminal turns logging off. This setting does not
change the node-storage capture controlled by `log start` and `log stop`.
For a USB-to-MQTT bridge, enable packet logging but leave `usb.debug` off.
Full Companion now supplies the ASCII RAW/RX/TX packet stream needed by such
consumers; it does not provide durable or lossless capture. A compatible host
must also support the role's identity/control commands and replies: packet
records alone do not make an existing bridge compatible. Companion now answers
`get public.key` with its node public key. ESP32 Full's logging TTY remains
input-capable and accepts that query. nRF52 Full's dedicated logging CDC is
not a command/reply console (its bounded stats-poll input only renews the
watchdog client lease):
a host integration must use interface `00` for commands and identity and
interface `02` for logs.
Existing single-port bridges that require CLI replies cannot simply point at
the dedicated log endpoint. This change does not add a two-port bridge driver.
Companion, Repeater, Room Server, and Sensor builds with both MQTT and USB
logging provide the same saved selector for both output paths:
@@ -1143,6 +1216,8 @@ in `both` mode; Full Companion starts with USB logging off. To change only
MQTT, use `set mqtt.enabled on|off`; `get mqtt.enabled` checks the saved
switch and `get mqtt.running` checks whether the MQTT service is running.
Turning MQTT off preserves all broker slots and credentials.
The output selector changes the USB master, not saved `usb.debug` intent;
WiFi MQTT logging is independent of the USB debug preference.
On **1.17.1.5 ESP32**, run `set powersaving off` before selecting
`set logging.output usb` or `set logging.output both`, since those modes
@@ -1895,7 +1970,7 @@ through binary command `0x42`; this command does not report the Bluetooth PIN.
**Note:** On nRF52 boards with power management and a boot-voltage setting,
this is a saved *relative* calibration: `1.000` keeps the board conversion,
`1.050` raises all reported battery millivolts by 5%, and `0` resets to
`1.000`. The allowed range is `0.500`–`1.500`. This affects boot protection,
`1.000`. The allowed range is `0.500`-`1.500`. This affects boot protection,
running cutoff, telemetry, battery alerts, and percentage. Calibrate against a
voltmeter before enabling a low cutoff. Other boards retain their existing
board-specific multiplier behavior, or return unsupported.
@@ -2167,6 +2242,13 @@ get clock.sync.status
#### View this node's public key
**Usage:** `get public.key`
Returns this node's identity public key without changing it or exposing its
private key. Companion supports the query through its text terminal and
framed CLI command `0x42` (`CMD_RUN_CLI_COMMAND`); its reply is `> ` followed
by exactly 64 uppercase hexadecimal characters. Private-key export support
is not required. On nRF52 Full Companion, send the query to control interface
`00`, not the output-only logging interface `02`.
---
#### View this node's firmware version
@@ -4909,12 +4991,12 @@ get adc.multiplier
set adc.multiplier 1.050
```
The custom empty/full endpoints may be 2000–4200 mV and must be at least 100 mV
The custom empty/full endpoints may be 2000-4200 mV and must be at least 100 mV
apart. Setting either endpoint automatically selects `custom`; boot lock and
cutoff remain separately adjustable. `get pwrmgt.bootlock` and
`get pwrmgt.cutoff` show the values in force after selecting a profile.
For a RAK3401/RAK13302 LiFePO₄ cell:
For a RAK3401/RAK13302 LiFePO4 cell:
```text
set battery.profile lifepo4
+30
View File
@@ -173,6 +173,36 @@ base where supported; RC32 and ESP32-C6 keep their board-required Arduino 3.x
platform but follow the same one-TTY policy. No ESP32 Full build enumerates a
second CDC interface.
`usb.logging` is the saved master for USB packet output. `usb.debug` separately
saves verbose diagnostic intent and defaults off, including when older
preferences do not contain the new field. RAW/RX/TX packet records need only
the master; diagnostics need both switches and compiled debug support. Turning
the master off preserves debug intent. `get usb.debug` reports that saved
choice, not whether USB currently owns a plaintext logging stream.
Full Companion now emits complete received frames as uppercase ASCII `RAW:`
hex, separately from decoded RX summaries and TX summaries. Leave debug off
for USB-to-MQTT capture. These are bounded best-effort records; drops and host
disconnects can separate a RAW/summary pair. The dedicated nRF52 log CDC cannot
answer CLI/identity queries: programs expecting one bidirectional CLI/log port
need separate control-port support, not just a change to the serial pathname.
Companion supports `get public.key` in the primary terminal or framed CLI;
the reply is `> ` followed by 64 uppercase hex characters. This query does not
make the dedicated log CDC input-capable or establish full bridge compatibility.
Send `get/set usb.debug` to the primary ASCII terminal; changing verbosity
needs no USB-interface reboot and does not relax Binary/mOTA ownership rules.
Current source adds `get usb.watchdog` and saved `set usb.watchdog off|on|auto`, defaulting to
Auto until 14 continuous healthy logging-client days qualify it for On.
Existing USB stats polls renew a 15-minute client lease; a cable or DTR alone
does not count. A lost confirmed client can trigger USB-only recovery after
lease expiry plus five minutes. MCU resets additionally require On and a
full physical-fault interval, a verified tier save, and clear protocol/update
ownership. Active Binary USB and mOTA sessions veto recovery. On dual CDC,
only logging CDC1 accepts the bounded stats markers; primary CDC0 stats do not
renew that lease. CDC1 still provides no CLI/statistics replies.
See [USB logging watchdog](usb_logging_watchdog.md) for status and limitations.
With logging enabled on a shared port, the stop token is rejected with
`ERROR: set usb.logging off before Binary mode`. Turn logging off first; a
successful binary-mode acknowledgement must not immediately revert to logging.
+34 -6
View File
@@ -1,6 +1,6 @@
# Management report decoder
Paste MGR1 management-report payloads or complete GroupData packets from a
Paste MGR1, MGR2, or MGR3 management-report payloads or complete GroupData packets from a
packet analyzer to read a radio's public management information. Everything,
including password authentication and ACL decryption, happens locally in this
browser. The report, password, and candidate public key are never uploaded.
@@ -13,13 +13,13 @@ by someone who does not know that password.
## Decode a management report
<div class="management-tool" data-management-decoder>
<label for="management-packet-input">MGR1 payload or complete GroupData packet hex</label>
<label for="management-packet-input">MGR1/MGR2/MGR3 payload or complete GroupData packet hex</label>
<textarea
id="management-packet-input"
data-role="input"
spellcheck="false"
autocomplete="off"
placeholder="Paste analyzer Raw Data, canonical MGR1 payload hex, or one MQTT raw value per line"
placeholder="Paste analyzer Raw Data, canonical MGR1/MGR2/MGR3 payload hex, or one MQTT raw value per line"
aria-describedby="management-packet-help"
></textarea>
<p class="management-help" id="management-packet-help">
@@ -31,7 +31,7 @@ by someone who does not know that password.
<label for="management-password-input">Management password <span>(optional for public fields; required for ACLs)</span></label>
<input id="management-password-input" data-role="password" type="password" autocomplete="new-password">
<p class="management-help">
The password remains in this page only. The decoder derives the MGR1 AES-SIV key in
The password remains in this page only. The decoder derives the management AES-SIV key in
your browser and does not send it anywhere.
</p>
@@ -67,9 +67,10 @@ by someone who does not know that password.
## What the decoder accepts
- Complete `PAYLOAD_TYPE_GRP_DATA` (`0x06`) analyzer/MQTT packet hex. It checks
the MeshCore route header, encoded path length, MGR1 page bounds, and required
the MeshCore route header, encoded path length, version-specific page bounds, and required
zero padding.
- A canonical MGR1 payload beginning with `4D475231` (`MGR1`).
- A canonical payload beginning with `4D475231` (`MGR1`), `4D475232` (`MGR2`),
or `4D475233` (`MGR3`).
- One raw packet or canonical payload per line; duplicate observations of an
identical page are deduplicated.
@@ -78,5 +79,32 @@ the report-specific fingerprints and the administrator and/or OTA-signer flags.
It cannot turn a fingerprint back into a full key. Use the optional candidate
field to test a specific full public key.
MGR2 and MGR3 also show USB logging, host/reader connection, logger-client activity, stalled/recovering/deferred
state, recovery stage, saved backoff, retry interval/inactive seconds, Auto qualification
seconds, and persistence
readiness. Unsupported observation is shown explicitly; legacy MGR1 has no
USB block and shows it as unavailable. These values are the report-start
snapshot, not proof that a Pi or host application is healthy. Older decoder
copies require an update for MGR3.
MGR3 additionally shows the latest USB watchdog action, reason mask and names,
advisory node-clock timestamp, recorded-boot uptime seconds, event sequence,
and whether the record was durably saved. Software recovery and re-enumeration
mean attempted actions, not successful repairs. A reboot request is not proof
that a reboot completed; a later cancellation can replace it. The node clock
can be wrong, even when it resembles a valid date. Event uptime and sequence
remain useful for ordering. MGR1/MGR2 do not carry this event field and show it
as unavailable. All 36 ACL entries still fit, using up to nine MGR3 pages.
Reports contain a frozen latest-event snapshot, not immediate watchdog alerts
or a complete event history. See [USB logging watchdog](usb_logging_watchdog.md)
for local review and persistence limitations.
Host/reader connection only describes the USB link or open reader. Logger-client
activity means recent USB stats polling within a 15-minute lease, renewed by
the existing five-minute polls without a new registration or heartbeat command.
The flag also requires logging enabled and a connected, unstalled USB link.
It does not identify the client or prove MQTT delivery, disk writes, or Pi health.
For the report schedule, public-field layout, cryptographic design, and the
offline Python capture tool, see [Management reports](management_reports.md).
+100 -18
View File
@@ -1,4 +1,4 @@
# Management reports (MGR1)
# Management reports (MGR1/MGR2/MGR3)
Available on repeater (including observer), room-server and sensor firmware.
Companions/terminal-chat nodes and KISS modems do not originate these reports.
@@ -6,6 +6,11 @@ Off by default. A management password and explicit enable are both required.
No radio settings, existing preferences layout, or OTA authorization policy is
changed by enabling reporting. This protocol does **not** authorize updates.
Current firmware emits `MGR3`, including a public USB logging/recovery snapshot
and its latest watchdog event. Updated decoders still accept legacy `MGR1` and
`MGR2`; older decoder copies need an update to read MGR3. Reporting USB status
does not enable logging or change retry policy.
## CLI
Run through the existing local CLI or an authenticated administrator session:
@@ -48,14 +53,16 @@ identifies the reporter.
Direct and flood schedules are independently configurable and may each be
turned off. Fresh settings are `direct=5d` and `flood=21d`; global reporting is
still off until `mgmt.enabled on`. Direct accepts 5–90 days and requires the
shared path. Flood accepts 21–90 days and requires a resolvable shared region.
still off until `mgmt.enabled on`. Direct accepts 5-90 days and requires the
shared path. Flood accepts 21-90 days and requires a resolvable shared region.
At least one route must remain active while reporting is enabled. The legacy
`set mgmt.interval N` shorthand enables both, setting direct to `N` and flood to
`max(21,N)`. No transmission is sent merely by configuring a password. Initial
reporting waits for the configured schedules. Reports have deterministic
per-radio/per-sequence jitter of up to an hour; pages are spaced at least a
minute apart.
minute apart. Watchdog events appear in the next scheduled snapshot, not as
immediate radio alerts. Multiple events between snapshots can replace the
latest-event record; this is not a complete off-node event history.
The radio cannot know that an observer uploaded a packet to MQTT, so the flood
schedule is deliberately independent of direct transmission. A region-scoped
@@ -90,8 +97,8 @@ is bounded to 1.5 KiB, plus a small configuration object and temporary stack use
## Routing and MQTT
Both direct/path and flood reports use **`PAYLOAD_TYPE_GRP_DATA` (`0x06`)**.
The route bits independently select direct or flood. `MGR1` is an application
extension with **literally plaintext public fields**, not a call to the ordinary
The route bits independently select direct or flood. `MGR1`/`MGR2`/`MGR3` are application
extensions with **literally plaintext public fields**, not a call to the ordinary
encrypted `createGroupDatagram()` builder. A fixed public marker is not an owner
or password-derived channel ID. There is no outer channel encryption.
@@ -108,7 +115,7 @@ not flood-route it.
The observer's existing MQTT `PACKET` JSON supplies the complete frame in `raw`.
No broker configuration or password changes are necessary to capture a report.
The decoder understands all four route forms, 1–3-byte hashes, scope transport
The decoder understands all four route forms, 1-3-byte hashes, scope transport
codes, and duplicate copies heard by several uplinks:
```
@@ -126,11 +133,11 @@ For a browser-local decoder that also decrypts and authenticates ACL entries,
use the [Management report decoder](management_decoder.md). It does not upload
the captured packet or password.
## Canonical payload (little endian)
## Canonical MGR3 payload (little endian)
| Offset | Bytes | Field |
|---:|---:|---|
| 0 | 4 | `MGR1` |
| 0 | 4 | `MGR3` |
| 4 | 16 | First 16 bytes of reporter public key |
| 20 | 4 | Persisted report sequence (never wraps; exhaustion stops TX) |
| 24 | 4 | RTC Unix timestamp; advisory, may be wrong |
@@ -142,7 +149,7 @@ the captured packet or password.
| 52 | 4 | Staging capacity; planning still checks actual package geometry |
| 56 | 4 | OTA capability bits |
| 60 | 2 | Uptime hours, saturated at 65535 |
| 62 | 4 | Weekly minimum mV, minimum °C, maximum °C |
| 62 | 4 | Weekly minimum mV, minimum C, maximum C |
| 66 | 4 | Since-report extrema in the same format |
| 70 | 1 | History coverage hours, capped at 168 |
| 71 | 1 | Interval days for this report's direct or flood schedule |
@@ -152,13 +159,86 @@ the captured packet or password.
| 75 | 1 | Mask of status bits whose state is known |
| 76 | 2 | Validity/partial-history flags |
| 78 | 1 | Zero-based page index |
| 79 | 1 | Total pages (1–6) |
| 80 | 1 | Unique ACL count (0–36) |
| 79 | 1 | Total pages (1-9) |
| 80 | 1 | Unique ACL count (0-36) |
| 81 | 1 | First ACL index in this page |
| 82 | 1 | ACL entries on this page (0–6) |
| 83 | 13 × count | AES-SIV encrypted ACL entries |
| 82 | 1 | ACL entries on this page (0-4) |
| 83 | 2 | USB status flags |
| 85 | 1 | Packed USB recovery stage and retry backoff step |
| 86 | 4 | USB full-reboot retry interval seconds |
| 90 | 4 | USB inactive seconds |
| 94 | 4 | USB continuous Auto qualification seconds |
| 98 | 1 | Latest watchdog reason/action/durability code |
| 99 | 4 | Latest watchdog event RTC Unix epoch; advisory, zero if unavailable |
| 103 | 4 | Uptime seconds at that event's boot |
| 107 | 4 | Latest watchdog event sequence, saturated at 4294967295 |
| 111 | 13 * count | AES-SIV encrypted ACL entries |
| after ACL | 16 | Full AES-SIV authentication tag |
| after tag | 0–15 | Zero padding to group-compatible length; not part of canonical payload |
| after tag | 0-15 | Zero padding to group-compatible length; not part of canonical payload |
MGR3 retains all 36 ACL entries, using four per page and up to nine pages. The
largest canonical page is 179 bytes and already group-compatible;
the radio payload budget is unchanged. The USB snapshot is frozen with the
other public fields at report start and repeated on each page. It is not a
live host-health monitor.
USB flag bits 0-10 are, in order: supported, logging enabled, watchdog enabled,
host connected, reader connected, stalled, recovering, recovery deferred,
persistence ready, watchdog Auto, and logger active. Bits 11-15 must be zero.
Byte 85 bits 0-1 hold stage: 0 idle, 1 soft-recovery wait, 2 re-enumeration
wait, and 3 reboot pending. Bits 2-5 hold the saved backoff step; bits 6-7
must be zero. Backoff is not a count of successful recoveries. The three
timers are unsigned seconds; local recovery/reboot counters are not sent.
The retry field is the saved backoff interval, not a countdown to an action;
inactive age is reported separately. USB-only cleanup can precede that interval.
Unsupported USB observation/recovery is explicitly represented by a false
supported bit, not omitted or mistaken for a connected healthy reader.
USB status describes the radio's observed transport and policy, not proof
that the host process, MQTT uplink, SSH service, or Pi is healthy.
Host/reader connection alone does not mean a logging client is running.
The logger-active bit means recent USB stats polling within a 15-minute lease
while logging is enabled and the observed USB host/reader link is not stalled,
separate from USB attachment or an open reader. The existing five-minute stats
polls renew this lease; no new client-registration or heartbeat command is
required. This does not identify the client, prove logs reached MQTT or disk,
or show that the Pi remains otherwise healthy.
Missing watchdog state on new or upgraded installations defaults to Auto.
Auto promotes to saved On after 14 continuous healthy logging-USB days with
renewed stats polling; its qualification age resets on reboot, detach, stall,
stats-lease expiry, or USB logging off.
Explicit On and Off remain available. Nonlogging/no-reader nodes remain
Auto without watchdog board reboots. The Auto flag and age report this
qualification separately from an enabled On policy.
Use the node's `get usb.watchdog` and `set usb.watchdog off|on|auto` CLI to
inspect or change policy; receiving a management snapshot changes nothing.
The 13-byte latest-event block is all zero when no event is available. Otherwise
byte 98 bits 0-3 are the reason mask: host absent, reader absent, TX stalled,
and confirmed stats client inactive. Bits 4-6 hold the action: 1 software
recovery attempted, 2 re-enumeration attempted, 3 reboot requested, or 4 reboot
cancelled after a final health/ownership check. Bit 7 means the event was saved
and verified on durable storage. Sequence is nonzero for a present event.
Physical faults may combine reason bits; stats-only expiry cannot authorize a
board reboot. Epoch zero is allowed when the clock source is unavailable.
Event time is the node's advisory clock, not verified UTC. Default clocks can
start at a plausible fixed date. Clock corrections do not rewrite old events;
recorded uptime and sequence help review them independently. A saved reboot
request is intent, not proof of a completed reset. The latest event survives
normal reboot and mode changes, but is not a log of hardware hangs or sudden
power failures. See [USB logging watchdog](usb_logging_watchdog.md) for storage
failure, cancellation, and review limitations.
Legacy MGR2 retains the same offsets through byte 97, followed by encrypted ACL
entries at byte 98 and the authentication tag. It carries five entries per page
and up to eight pages. Its USB status is available, but watchdog event history
is unavailable rather than an empty modern record.
Legacy MGR1 retains the same offsets through byte 82, followed immediately
by encrypted ACL entries at byte 83 and the authentication tag. It carries
six entries per page and up to six pages, with no USB block. Its absent USB
status is decoded as unavailable, not as all switches off.
Each private entry is a per-radio 12-byte keyed fingerprint followed by flags:
bit 0 administrator, bit 1 trusted OTA signer. Duplicate entries combine flags.
@@ -175,9 +255,9 @@ means compiled support; active means an established usable apply/store path.
Validity bits: 0 firmware version, 1 bootloader version, 2 EndF/base identity,
3 staging capacity, 4 partial week, 5 partial since-report period, 6 MCU temperature.
Voltage is unsigned millivolts, zero missing. Temperatures: zero missing,
1–251 represent −50…200 °C, 252 below range, 253 above range, 254–255 reserved.
1-251 represent -50..200 C, 252 below range, 253 above range, 254-255 reserved.
OTA bits: 0 protocol compiled, 1 transfer DEFLATE, 2 2-KiB app transfer blocks;
bits 8–23 are apply codec bits (full/sequential/in-place). Transfer DEFLATE is
bits 8-23 are apply codec bits (full/sequential/in-place). Transfer DEFLATE is
not compressed bootloader apply. There is **no manifest ID**. Exact old binaries
are still needed on the computer to generate a differential update; a hash alone
cannot reconstruct them. Unknown metadata must not be treated as OTA readiness.
@@ -193,7 +273,9 @@ needs a candidate administrator's full key to identify a fingerprint.
Encryption is [RFC 5297 AES-SIV-CMAC-256](https://www.rfc-editor.org/rfc/rfc5297),
using the existing rweather AES primitive. The single associated-data string is
the complete 83-byte clear header. Ciphertext is exactly the ACL byte length.
the complete clear header: 111 bytes for MGR3, including USB status and its event,
98 bytes for MGR2, or 83 bytes for MGR1. All retain the existing derivation domains; the authenticated
magic and layout distinguish their wire versions. Ciphertext is exactly the ACL byte length.
Full 16-byte tag, no truncation. This deterministic misuse-resistant mode avoids
reliance on the firmware's noncryptographic general-purpose RNG or RTC nonces.
An identical restored snapshot may repeat ciphertext, but does not expose XORs
+31 -1
View File
@@ -1,4 +1,4 @@
# Feature switches by role — 1.17.1.5 USA Cascade
# Feature switches by role - 1.17.1.5 USA Cascade
Use these settings with the exact board's canonical release image. Former
logging, power-saving, FEM-gain, and rotated-display variants are now runtime
@@ -71,6 +71,16 @@ change them. A command requires its feature to be compiled into the build.
`set usb.logging on|off` changes only USB logging. On MQTT-capable builds,
`set mqtt.enabled on|off` changes only MQTT and keeps broker settings;
`set logging.output off|usb|wifi|both` selects both outputs together.
`set usb.debug on|off` separately saves verbose USB diagnostics. Debug output
requires both USB switches on; RAW/RX/TX packet records need only
`usb.logging`. Leave `usb.debug off` for USB-to-MQTT packet capture.
Current source also includes a saved [USB logging watchdog](usb_logging_watchdog.md):
`get usb.watchdog` and `set usb.watchdog off|on|auto`. Missing state defaults to
Auto, qualifying from 14 continuous healthy USB stats-polling days, not a cable
or open TTY alone. Confirmed client loss can trigger USB-only repair; MCU resets
require On and an entire physical-fault tier, growing from one hour to one week.
This repairs the node's USB connection, not the host Pi or MQTT service.
## Full Companion commands
@@ -83,6 +93,8 @@ change them. A command requires its feature to be compiled into the build.
| External FEM TX gain | `set radio.fem.txgain on` | `set radio.fem.txgain off` | `get radio.fem.txgain` |
| ESP32 USB logging | `set powersaving off`, then `set usb.logging on` | `set usb.logging off` | `get powersaving`, `get usb.logging` |
| nRF52 second USB logging port | `set usb.logging on reboot` | `set usb.logging off reboot` | `get usb.logging` |
| Verbose USB diagnostics | `set usb.debug on` while USB logging is on | `set usb.debug off` | `get usb.debug` reports saved intent |
| USB logging watchdog (current source) | `set usb.watchdog auto` or `on` | `set usb.watchdog off` | `get usb.watchdog`; native USB and durable storage required |
| MQTT master | `set mqtt.enabled on` | `set mqtt.enabled off` | `get mqtt.enabled`, `get mqtt.running`, `get mqtt.status` |
| ESP32 persistent WebConfig | `set webui on` | `set webui off` | `get webui` |
| ESP32 WebConfig browser console | `set wifi.cli on` | `set wifi.cli off` | `get wifi.cli` |
@@ -94,11 +106,19 @@ Saved settings apply immediately unless noted. Rotation and gain controls
report unsupported hardware instead of creating that feature. Fresh Full
Companion preferences enable device power saving and leave USB logging off;
existing saved preferences win after an update.
USB debug defaults off, including on upgrade from preferences without that
field. Its saved intent survives switching the USB master off. Verbose messages
still require compiled debug support; changing this preference adds no missing
code and does not require a reboot.
**ESP32:** logging and binary Companion traffic share one USB port. Turn logging
off before handing USB to an app/MOTA host. **nRF52:** the optional second CDC
port is for logs; use the primary port for Companion/MOTA. Adding/removing the
second port requires a reboot; the optional suffix shown above requests it.
Full Companion includes complete ASCII RAW packet records and decoded RX/TX
summaries, independent of verbose debug. The nRF52 log port is output-only;
CLI/identity queries belong to interface `00`. A USB-to-MQTT program that expects
both logs and CLI replies on one port cannot use interface `02` alone.
### MQTT controls shared by Companion and infrastructure
@@ -167,6 +187,7 @@ from some portable builds. The USB browser console still works without it.
| Setting | Enable | Disable | Read back |
| --- | --- | --- | --- |
| Live USB logging | ESP32 1.17.1.5: `set powersaving off`, then `set usb.logging on`; other platforms: `set usb.logging on` | `set usb.logging off` | `get powersaving`, `get usb.logging` |
| Verbose USB diagnostics | `set usb.debug on` while USB logging is on | `set usb.debug off` | `get usb.debug` reports saved intent |
| Capture RX log to node storage | `log start` | `log stop` | `log` prints the capture locally |
| RS232 / ESP-NOW bridge master | `set bridge.enabled on` | `set bridge.enabled off` | `get bridge.enabled`, `get bridge.running`, `get bridge.type` |
| MQTT periodic status publication | `set mqtt.status on` | `set mqtt.status off` | `get mqtt.status` shows connection status |
@@ -189,6 +210,13 @@ nRF52 Full Companion. `log start/stop` records to storage independently of live
USB logging. Use
`log erase` to delete that capture.
For quiet packet capture in current firmware, use `set usb.debug off` and
`set usb.logging on`. Verbose diagnostics default off for fresh and upgraded
preferences without the new field. The debug choice is saved separately;
turning logging off, or selecting `logging.output wifi`, does not erase it.
Debug output is effective only when USB logging is on and the image includes
the relevant debug code. RAW/RX/TX records remain enabled without debug.
For **ESP32 1.17.1.5 USB logging**, run these as separate commands in the
role's text terminal (or remote admin CLI on infrastructure):
@@ -232,6 +260,8 @@ settings override these defaults. To toggle only MQTT while keeping
USB logging unchanged, use `set mqtt.enabled off` / `on`. Neither setting
turns LoRa repeating off. Repeater forwarding uses `set repeat off` / `on`
and `get repeat` separately.
The selector changes USB master output, not saved `usb.debug` intent. Direct
WiFi MQTT is independent of that USB verbosity preference.
The `set powersaving off` step above is the **1.17.1.5 ESP32 USB workaround**.
**WiFi/MQTT-only logging does not need it while the Repeater/Room Server MQTT
+28 -3
View File
@@ -55,6 +55,7 @@ the corresponding feature:
| Command | Result |
|---|---|
| `stats-core`, `stats-radio`, `stats-radio-diag`, `stats-packets` | Runtime diagnostics |
| `get public.key` | Read-only node identity public key; Companion returns `> ` followed by 64 uppercase hex characters |
| `get prv.key` | Node identity private key; Companion requires private key export enabled in the build |
| `get password` | Infrastructure admin password; Companion reports that it has no admin password |
| `erase` | Erase stored identity and settings; reboot for a fresh node |
@@ -264,10 +265,28 @@ GPS idle behavior; it does not change LoRa RXPS or WiFi modem sleep.
```
get usb.logging
set usb.logging {on|off} [reboot]
get usb.debug
set usb.debug {on|off}
```
Shows or changes persistent live USB debug and packet output in an ordinary
USB-loggable Companion or Full Companion. USB Companion and Full start off on
a fresh install with logging disabled to protect framed traffic.
Shows or changes persistent USB packet output and a separate verbose-debug
preference in an ordinary USB-loggable Companion or Full Companion.
`usb.logging` is the master; debug output requires the master and `usb.debug`
to be on. RAW and RX/TX packet records need only the master. Use
`set usb.debug off` with packet logging for USB-to-MQTT capture.
`get usb.debug` reports saved intent, even while the master is off. Disabling
the master does not clear it. Debug defaults off for fresh installs and older
preferences without the new field; it still needs compiled debug support.
Changing debug verbosity takes effect without a reboot.
Current source also accepts `get usb.watchdog` and saved
`set usb.watchdog off|on|auto`. Auto is the default and requires 14 continuous
healthy logging-client days before saving On. Exact USB stats polls, not merely
USB attachment, renew the client lease. See
[USB logging watchdog](usb_logging_watchdog.md) for USB-only repair, increasing
physical-fault reboot intervals, status fields, and ownership/storage safeguards.
USB Companion and Full start off on a fresh install with the logging master
disabled to protect framed traffic.
nRF52 Full changes its interface count after a reboot and keeps Companion on
interface `00`. Every ESP32 Full Companion has one TTY and needs no reboot:
logging uses the active text terminal, disables framed Binary Companion on
@@ -275,6 +294,12 @@ USB, and continues accepting `set usb.logging off`. Turning logging off leaves
the port in the normal ASCII terminal, matching fresh firmware. Send
`+++MESHCORE-TERM-STOP`, or let a Companion app send a valid framed probe, to
switch it to Binary Companion afterward.
Full Companion emits complete received frames as uppercase ASCII `RAW:` hex
followed by decoded RX summaries where possible; TX summaries are separate.
These are best-effort records, not atomic pairs or a durable capture.
nRF52 interface `02` is logging-only: send CLI/control commands on interface
`00`. A single-port bridge that queries identity on its logging port needs
separate control-port support before it can use that dedicated log endpoint.
```
reboot
@@ -1,7 +1,16 @@
# Full USB logging repair — 1.17.1.6
# Full USB logging repair - 1.17.1.6
Audit date: 2026-09-13. Release firmware source: `306feebe6d648a247f925894f876be9d747fc8d2`.
This is historical qualification of the 1.17.1.6 images, not an all-board
runtime test of later firmware. Current source separates the saved USB output
master (`usb.logging`) from opt-in diagnostic verbosity (`usb.debug`, default
off), and adds full ASCII RAW records to Full Companion. See
[current USB logging controls](../cli_commands.md#control-live-usb-logging)
for defaults, legacy preference handling, capture-loss behavior and dedicated
CDC/control-port restrictions. These source changes do not imply new hardware
validation of the published images listed below.
The original Station G2 Full Repeater and Full Room Server images omitted USB
packet logging. Both inherited `-UMESH_PACKET_LOGGING` from their ordinary MQTT
observer recipes. Full added `-DMESH_PACKET_LOGGING=1`, but PlatformIO's
+198
View File
@@ -0,0 +1,198 @@
# USB logging watchdog
Current source includes a saved USB logging watchdog in Companion, Repeater,
Room Server, and Sensor. It repairs the radio's USB connection; it cannot
restart a Pi, restore SSH, or prove MQTT or disk delivery. Supported backends
are ESP32 native TinyUSB/HWCDC and nRF52 TinyUSB. An external USB-to-UART
adapter is not an observable native-USB backend.
Unsupported targets compile out recovery timers and statefile transactions;
their watchdog commands report unsupported rather than saving an unusable mode.
Management still reports the logging master with USB support false.
## Commands and defaults
```text
get usb.watchdog
get usb.watchdog.last
set usb.watchdog auto
set usb.watchdog on
set usb.watchdog off
```
Setters accept exactly one lowercase mode, save it before applying, and do not
reboot immediately. They do not enable `usb.logging` or `usb.debug`.
| Mode | Behavior |
| --- | --- |
| `auto` | Default for missing watchdog state, including upgrades. Requires 14 continuous healthy logging-client days before saving `on`, with the retry tier reset to one hour. A previously confirmed client can receive USB-only recovery before promotion, but Auto never reboots the MCU. |
| `on` | Explicitly arms physical USB recovery and eventual MCU reboot. A confirmed stats client also gets USB-only recovery if its stats polls stop; missing polls alone cannot authorize MCU reboot. |
| `off` | No watchdog probes, automatic USB recovery, qualification, or MCU reset. Packet logging remains independent. |
Existing valid saved On/Off choices win. Auto qualification is RAM-only and
restarts on reboot, USB disconnect, observed stall, expired client lease,
logging master Off, or an explicit mode change. A node that has never confirmed
a logging client does not cycle USB in Auto merely because no cable is attached.
## Confirming a logging client
The existing `meshcoretomqtt` five-minute `stats-core`, `stats-radio`, and
`stats-packets` polls renew a 15-minute lease. Only complete, exact stats
commands received on the actual USB logging endpoint count; BLE, network,
LoRa, management reports, and ordinary framed Companion traffic do not.
No new heartbeat command is required. A cable, open TTY, DTR, or kernel USB
ACK alone cannot qualify Auto.
`logger=1` requires the USB logging master, an observed host and reader, a fresh
stats lease, and no physical TX stall. Any program polling those commands can
qualify: this is not application identification or proof that packets were
parsed, published to MQTT, or written to disk. A one-time manual stats query
can also establish the lease; use watchdog Off when later USB-only reconnection
would be unwanted.
On nRF52 Full Companion, only the dedicated logging CDC (`*-if02`) establishes
the lease. That endpoint accepts bounded stats-poll markers without replying
with statistics or exposing arbitrary CLI/configuration commands. Queries and
configuration still use primary CDC (`*-if00`), whose stats queries do not
prove a logging reader. Existing single-port bridges needing identity/CLI
replies still require separate control-port support.
## Recovery timing and safety
For a physical host/reader outage, or pending TX with no real completion,
software USB recovery is requested after five minutes. Re-enumeration follows
after another 60 seconds if health is not restored. These are one staged
attempt per outage, not a repeated five-minute reboot loop. Busy backends retry
at most once every five seconds without consuming an attempted stage.
After a confirmed client's stats lease expires, USB-only repair is due five
minutes later: normally 20 minutes after its last stats poll. This applies in
Auto and, once a client is confirmed during that boot, On. On with no stats
client observed remains compatible with non-stats log readers. A gap in stats
polls does not by itself reboot a healthy USB transport.
MCU reboot requires On, a physical USB fault lasting the entire saved tier,
and the USB recovery grace periods. Tiers grow after reset authorization:
1, 2, 4, 8, 16, 32, 64, 128, then 168 hours (one week). An older app-only
stats outage cannot shorten a later physical fault's interval. Ten minutes
of healthy monitored operation resets a raised tier to one hour.
Native TX-completion events are the progress evidence. Queueing, purging,
reinitializing, or accepting the optional bounded minute probe is not proof
of recovery. The watchdog never waits for a host. USB connection reports,
especially HWCDC's host/reader flags, are SDK approximations and do not reliably
identify an open TTY process.
Recovery waits for active Binary USB clients, mOTA/update owners, unsaved
contacts, radio tests/calibration/temporary-radio operations, functional CLI
work, and selected/in-flight radio transmissions to clear. Stale ASCII output
from a disconnected or stalled host does not indefinitely prevent recovery.
Before MCU reset, the next tier must commit and pass readback; the firmware
then rechecks transport health and ownership. Re-enumeration preserves the
configured USB descriptors, but can disconnect host applications and lose
best-effort packet records.
## Persistence and status
The independent, versioned, CRC-protected `/usb_wdg` statefile does not
change existing role preference offsets. Unreadable/corrupt state or a volatile
fallback filesystem disables automatic recovery/promotion. Failed commits
remain fail-closed; an explicit setter can retry or repair this statefile on
durable storage, applying only after complete verification. It cannot make
volatile storage durable. A verified backup repairs a conclusively missing
primary after an interrupted rename; unreadable live state is not replaced
automatically.
Current writes use the 33-byte UW2 format, including the latest watchdog event.
Valid 20-byte UW1 files still load with their saved mode, tier, and counters;
they upgrade on the next legitimate event/configuration save, not merely on
boot. Each attempted software recovery or re-enumeration saves its event and
counters once. Probes, idle ticks, ownership/backend deferrals, and status
reads do not write flash. A failed save leaves the live event marked unsaved
and automatic actions fail closed until explicit repair.
### Last-event review
```text
get usb.watchdog.last
```
This returns the latest event only, not an unbounded history. New events replace
older ones; exporting periodic management reports can retain observations
off-node. Mode changes preserve the record. Erasing the filesystem or restoring
a backup can remove or roll back it.
The event includes an advisory Unix epoch from the node's RTC, uptime seconds
at that recorded boot, a saturating event sequence, action, reason bitmask, and
durability status. Uptime extends the millisecond clock across rollover.
RTC time is not verified UTC: default clocks can start at a plausible fixed
date, and a later time correction does not rewrite old events. Epoch zero
is allowed and can mean no usable timestamp source. Use a synchronized node
clock for meaningful calendar times; sequence and recorded uptime remain useful when
clock time is wrong.
Reason bits are `1` host absent, `2` reader absent, `4` physical TX stalled,
and `8` previously confirmed stats client inactive. Physical failures can set
multiple bits; a stats-only expiry uses bit 8 and cannot authorize an MCU reset.
Actions are `soft-recovery`, `reenumerate`, `reboot-requested`, and
`reboot-cancelled`. Software actions mean the backend reported an attempt, not
that USB was successfully repaired. A reboot request is persisted with the
next backoff tier before authorization; health/ownership are rechecked after
the commit. A subsequent veto records cancellation immediately in RAM. If OTA
or another owner makes storage unsafe, its durable save waits for a safe
application tick; power loss before that save can leave the earlier reboot
intent on disk. Neither the intent record
nor the existing reboot counter proves a completed physical reset. Power loss
or a hardware main-loop watchdog can occur without any opportunity to save
an exact trigger time; this record covers the staged USB logging watchdog.
`get usb.watchdog` includes these compact fields:
| Field | Meaning |
| --- | --- |
| `usb`, `log`, `host`, `reader`, `logger` | Backend supported; logging master; observed physical USB host/reader; confirmed healthy stats client. |
| `stall`, `stage`, `step` | Physical TX stall; stage 0 idle, 1 software wait, 2 re-enumeration wait, 3 reset pending; saved tier index 0-8. |
| `retry`, `idle`, `auto` | Saved MCU interval, current monitored fault age, and continuous Auto qualification age, in seconds. Retry is not an action countdown. |
| `defer`, `fs` | Ownership/backend deferral and verified durable persistence readiness. |
| `rec`, `boot` | Attempted USB recoveries and durable reset authorizations. Attempts save the event and recovery count together; a failed save can leave the live recovery count unsaved. The reset count can include a later safety/health veto; it is not a count of completed physical reboots. |
The [MGR3 management report](management_reports.md) exports the USB status and
the same latest-event record as a frozen, authenticated public snapshot. Updated
Python and browser decoders retain MGR1/MGR2 support; older decoder copies must
be updated to read MGR3. Events appear on the normal management schedule, not
as immediate alerts. Reading status does not renew the logging-client lease.
## Verification
Initial watchdog qualification on 2026-10-03, before the latest-event extension,
passed 109 USB regression tests, 143 native test cases, 11 management protocol
tests, and the browser decoder checks.
Tests execute the production policy, runtime, marker parser, role ownership
guards, and native USB boundaries with simulated clock, filesystem, and FIFO
faults. The 14-day qualification uses an advanced test clock, not a hardware
soak. Bounded console output, unread hosts, session races, and exact stats
markers are covered.
Those initial compiled checks passed Station G2 Full Companion, Repeater, and Room Server;
XIAO nRF52 Full Companion and Repeater (full and reduced OTA qualifications);
and ordinary RAK 3x72 STM32 USB Companion. The STM32 image retains all CLI
features and its 32 KiB filesystem, but has only 368 bytes of app flash margin;
its unsupported USB watchdog machinery is compiled out.
Latest-event/MGR3 verification on the same date passed 116 USB regression tests,
15 management tests with fatal AddressSanitizer/UndefinedBehaviorSanitizer
checks, and the browser decoder tests. Coverage includes timestamp callbacks,
clock corrections and rollover, legacy-state migration, commit failures,
reboot-intent cancellation, deferred cancellation saves, and authenticated
cross-language MGR1/MGR2/MGR3 decoding. The full native suite passed 1,713 cases
during implementation; the final packet validator also passed a fresh 116-case
routing suite and 5,855,168 previous/current validator comparisons under fatal
sanitizers.
The extension compiled in standard Station G2 Full Companion and Repeater,
XIAO nRF52 Repeater, and RAK 3x72 USB Companion. The normal `build.sh` STM32
recipe at version `1.17.1.9` retains all CLI features and the 32 KiB filesystem,
with 372 bytes of app flash margin; longer version strings or future changes
can still exceed that tight budget. No features or filesystem space were cut
to fit this extension. This is not an all-board release qualification or a
physical USB recovery test. No hardware was flashed, and verification artifacts
stayed outside `out`.
+15
View File
@@ -1002,6 +1002,9 @@ bool DataStore::loadPrefsInt(const char *filename,
// Files written before this tail existed always started Bluetooth at boot.
// Do not inherit a runtime off value when loading one of those images.
loaded_prefs.bluetooth_enabled = 1;
// Older images have no independent debug preference. Never inherit a
// runtime-on value when loading one of those images.
loaded_prefs.usb_debug_enabled = 0;
double loaded_lat = node_lat;
double loaded_lon = node_lon;
// The original image ended after ble_pin at byte 84. Later releases only
@@ -1056,6 +1059,13 @@ bool DataStore::loadPrefsInt(const char *filename,
329,
#else
232,
#endif
#ifdef TBEAM_1W
240, // Independent USB debug preference, after the existing tail
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
330,
#else
233,
#endif
};
const uint32_t prefs_size = file.size();
@@ -1200,6 +1210,8 @@ bool DataStore::loadPrefsInt(const char *filename,
sizeof(loaded_prefs.one_key_dm_enabled));
readOptionalField(&loaded_prefs.bluetooth_enabled,
sizeof(loaded_prefs.bluetooth_enabled));
readOptionalField(&loaded_prefs.usb_debug_enabled,
sizeof(loaded_prefs.usb_debug_enabled));
// Any bytes left over form only part of a historically appended field.
// Preserve the file and defaults rather than treating that tail as EOF.
@@ -1207,6 +1219,7 @@ bool DataStore::loadPrefsInt(const char *filename,
success = success && loaded_prefs.bluetooth_enabled <= 1;
file.close();
if (!success) return false;
loaded_prefs.usb_debug_enabled = loaded_prefs.usb_debug_enabled == 1 ? 1 : 0;
_prefs = loaded_prefs;
node_lat = loaded_lat;
node_lon = loaded_lon;
@@ -1363,6 +1376,8 @@ bool DataStore::savePrefs(const CompanionNodePrefs& _prefs, double node_lat, dou
sizeof(_prefs.one_key_dm_enabled)) == sizeof(_prefs.one_key_dm_enabled);
success = success && file.write((uint8_t *)&_prefs.bluetooth_enabled,
sizeof(_prefs.bluetooth_enabled)) == sizeof(_prefs.bluetooth_enabled);
success = success && file.write((uint8_t *)&_prefs.usb_debug_enabled,
sizeof(_prefs.usb_debug_enabled)) == sizeof(_prefs.usb_debug_enabled);
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) || defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
success = file.commit(success);
+161 -54
View File
@@ -25,6 +25,10 @@
#include <helpers/StatsFormatHelper.h>
#include <helpers/UsbAsciiBinarySwitch.h>
#include <helpers/UsbLogging.h>
#include <helpers/UsbLoggingWatchdog.h>
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
#endif
#if defined(NRF52_POWER_MANAGEMENT)
#include <helpers/NRF52VoltagePolicy.h>
#endif
@@ -678,7 +682,34 @@ void MyMesh::updateGpsTelemetryPolicy() {
sensors.setTelemetryLocationAccessAvailable(hasLocationTelemetryRecipient());
}
#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT
const char* MyMesh::getLogDateTime() {
// The shared RX/TX summaries use this same virtual formatter. Keep the
// repeater's UTC record grammar so USB packet bridges can pair RAW and RX.
static char timestamp[32];
const DateTime now(getRTCClock()->getCurrentTime());
snprintf(timestamp, sizeof(timestamp), "%02u:%02u:%02u - %u/%u/%u U",
(unsigned)now.hour(), (unsigned)now.minute(), (unsigned)now.second(),
(unsigned)now.day(), (unsigned)now.month(), (unsigned)now.year());
return timestamp;
}
#endif
void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
#if MESH_PACKET_LOGGING
if (mesh::isUsbLoggingEnabled()) {
mesh::SerialLogLine<> line;
#if MESH_PACKET_LOGGING_COMPACT
line.printf("R");
line.hex(raw, len);
line.flush(mesh::usbLoggingPort(), false);
#else
line.printf("%s RAW: ", getLogDateTime());
line.hex(raw, len);
line.flush(mesh::usbLoggingPort());
#endif
}
#endif
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
if (_mqtt_bridge && _mqtt_bridge->isRunning()) {
_mqtt_bridge->storeRawRadioData(raw, len, snr, rssi);
@@ -2311,6 +2342,7 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe
#endif
memset(_prefs.bluetooth_name, 0, sizeof(_prefs.bluetooth_name));
_prefs.bluetooth_enabled = 1;
_prefs.usb_debug_enabled = 0;
_prefs.display_rotation_degrees = 0;
_prefs.cad_enabled = DEFAULT_CAD_ENABLED ? 1 : 0;
_prefs.cad_scan_timeout_ms = 0;
@@ -2586,6 +2618,7 @@ void MyMesh::begin(bool has_display, bool radio_available,
_prefs.powersaving_enabled = constrain(_prefs.powersaving_enabled, 0, 1);
_prefs.wifi_enabled = constrain(_prefs.wifi_enabled, 0, 1);
_prefs.usb_logging_enabled = constrain(_prefs.usb_logging_enabled, 0, 1);
_prefs.usb_debug_enabled = _prefs.usb_debug_enabled == 1 ? 1 : 0;
_prefs.cad_enabled = constrain(_prefs.cad_enabled, 0, 1);
if (_prefs.cad_scan_timeout_ms != 0
&& (_prefs.cad_scan_timeout_ms < mesh::CAD_SCAN_MIN_TIMEOUT_MS
@@ -2607,6 +2640,7 @@ void MyMesh::begin(bool has_display, bool radio_available,
}
#if MESH_USB_LOGGING_AVAILABLE
const bool usb_logging_enabled = _prefs.usb_logging_enabled != 0;
mesh::setUsbDebugEnabled(_prefs.usb_debug_enabled != 0);
mesh::setUsbLoggingEnabled(usb_logging_enabled);
if (!mesh::saveUsbLoggingBootPreference(usb_logging_enabled)) {
MESH_DEBUG_PRINTLN("Unable to save next-boot USB logging interface state");
@@ -3156,7 +3190,15 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
if (!command || !reply || reply_size == 0) return false;
while (*command == ' ') command++;
if (strcmp(command, "get public.key") == 0) {
char public_key_hex[PUB_KEY_SIZE * 2 + 1];
mesh::Utils::toHex(public_key_hex, self_id.pub_key, PUB_KEY_SIZE);
snprintf(reply, reply_size, "> %s", public_key_hex);
return true;
}
if (handleTxRoutingCommand(command, reply, reply_size)) return true;
if (mesh::handleUsbLoggingWatchdogCommand(command, reply, reply_size)) return true;
#if COMPANION_FEATURE_OTA_CLI
const auto* ota_context = mesh::ota::ota_context_if_active();
const float adaptive_ota_pace = ota_context
@@ -3241,6 +3283,40 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply,
#endif
#if MESH_USB_LOGGING_AVAILABLE
if (strcmp(command, "get usb.debug") == 0) {
snprintf(reply, reply_size, "usb.debug %s%s",
mesh::isUsbDebugEnabled() ? "on" : "off",
#if MESH_DEBUG
mesh::isUsbLoggingEnabled() ? "" : " (USB logging off)"
#else
" (core debug not compiled)"
#endif
);
return true;
}
if (strncmp(command, "set usb.debug", 13) == 0
&& (command[13] == 0 || command[13] == ' ' || command[13] == '\t')) {
const char* value = command + 13;
while (*value == ' ' || *value == '\t') value++;
if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) {
snprintf(reply, reply_size, "Error: use set usb.debug <on|off>");
} else {
const uint8_t enabled = strcmp(value, "on") == 0 ? 1 : 0;
if (!savePreference(_prefs.usb_debug_enabled, enabled)) {
snprintf(reply, reply_size, "Error: USB debug setting could not be saved");
} else {
snprintf(reply, reply_size, "OK - USB debug %s (saved)%s",
enabled ? "on" : "off",
#if MESH_DEBUG
""
#else
" (core debug not compiled)"
#endif
);
}
}
return true;
}
if (strcmp(command, "get usb.logging") == 0) {
snprintf(reply, reply_size, "usb.logging %s; port: %s%s",
mesh::isUsbLoggingEnabled() ? "on" : "off",
@@ -9131,11 +9207,12 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().printf("Companion %s (protocol %u, build %s)\r\n",
FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE);
} else if (strcmp(command, "help") == 0) {
terminalOutput().print("Commands:\r\n");
terminalOutput().print(" stats-core / stats-radio / stats-radio-diag / stats-packets\r\n");
terminalOutput().print(" get prv.key (when private key export is enabled)\r\n");
terminalOutput().print(" get password (this role has no admin password)\r\n");
terminalOutput().print(" erase (erase stored settings and identity)\r\n");
terminalOutput().print(
"Commands:\r\n"
" stats-core / stats-radio / stats-radio-diag / stats-packets\r\n"
" get prv.key (when private key export is enabled)\r\n"
" get password (this role has no admin password)\r\n"
" erase (erase stored settings and identity)\r\n");
terminalOutput().print(" board\r\n");
terminalOutput().print(" version\r\n");
terminalOutput().print(" get storage.layout\r\n");
@@ -9157,17 +9234,20 @@ void MyMesh::handleTerminalCommand(char* command) {
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");
terminalOutput().print(" get display.touch\r\n");
terminalOutput().print(" set display.touch <on|off> (this boot only)\r\n");
terminalOutput().print(" set {name|lat|lon|freq|tx|af} {value}\r\n");
terminalOutput().print(" get wifi|espnow|2.4ghz\r\n");
terminalOutput().print(" set wifi|espnow on|off [force] (force only with off)\r\n");
terminalOutput().print(" set 2.4ghz on [all|force]|off [force] (this boot)\r\n");
terminalOutput().print(" get bluetooth.name\r\n");
terminalOutput().print(" set bluetooth.name <name|default>\r\n");
terminalOutput().print(
" get display.inbox\r\n"
" set display.inbox <history|pending|unread>\r\n"
" set display.rotation <0|90|180|270>\r\n"
" get display.touch\r\n"
" set display.touch <on|off> (this boot only)\r\n");
terminalOutput().print(" get public.key\r\n");
terminalOutput().print(
" set {name|lat|lon|freq|tx|af} {value}\r\n"
" get wifi|espnow|2.4ghz\r\n"
" set wifi|espnow on|off [force] (force only with off)\r\n"
" set 2.4ghz on [all|force]|off [force] (this boot)\r\n"
" get bluetooth.name\r\n"
" set bluetooth.name <name|default>\r\n");
#if defined(BLE_PIN_CODE)
terminalOutput().print(" get bluetooth (alias: get ble)\r\n");
terminalOutput().print(" set bluetooth <on|off> [force] (saved; force only with off)\r\n");
@@ -9180,8 +9260,17 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" set pin <0-999999>\r\n");
terminalOutput().print(" powersaving [on|off]\r\n");
#if MESH_USB_LOGGING_AVAILABLE
terminalOutput().print(" get usb.logging\r\n");
terminalOutput().print(" set usb.logging <on|off> [reboot]\r\n");
terminalOutput().print(
" get usb.logging\r\n"
" set usb.logging <on|off> [reboot]\r\n"
" get usb.debug\r\n"
" set usb.debug <on|off>\r\n"
#if MESH_USB_CONSOLE_COOPERATIVE
" get usb.watchdog\r\n"
" get usb.watchdog.last\r\n"
" set usb.watchdog <off|on|auto> (saved; default auto)\r\n"
#endif
);
#endif
#if defined(ESP32) && defined(WIFI_SSID)
#if defined(COMPANION_RADIO_FULL)
@@ -9208,40 +9297,47 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" get espnow.channel\r\n");
terminalOutput().print(" set espnow.channel <1-13>\r\n");
#endif
terminalOutput().print(" get radio.rxps\r\n");
terminalOutput().print(" get radio.rxps.config\r\n");
terminalOutput().print(" set radio.rxps <off|on|level 1-10 [preamble 16|32]|rx_us sleep_us>\r\n");
terminalOutput().print(" get radio.cad\r\n");
terminalOutput().print(" set radio.cad <on|off>\r\n");
terminalOutput().print(" set radio.cad timings <scan_ms|auto> <retry_ms|auto> <max_ms|auto>\r\n");
terminalOutput().print(" get radio.rxgain\r\n");
terminalOutput().print(" set radio.rxgain <on|off>\r\n");
terminalOutput().print(" get radio.fem.rxgain\r\n");
terminalOutput().print(" set radio.fem.rxgain <on|off>\r\n");
terminalOutput().print(" get radio.fem.txgain\r\n");
terminalOutput().print(" set radio.fem.txgain <on|off>\r\n");
terminalOutput().print(" get/set flood.retry.count <0-15>\r\n");
terminalOutput().print(" get/set flood.retry.path <0-63|off>\r\n");
terminalOutput().print(" get/set flood.retry.group.path <0-63|off>\r\n");
terminalOutput().print(" get/set flood.retry.advert <on|off>\r\n");
terminalOutput().print(" card\r\n");
terminalOutput().print(" import <meshcore://card>\r\n");
terminalOutput().print(" clock\r\n");
terminalOutput().print(" time <epoch-seconds>\r\n");
terminalOutput().print(" list [n]\r\n");
terminalOutput().print(" show [adverts|channels|emergency] [on|off]\r\n");
terminalOutput().print(" to [recipient name or prefix]\r\n");
terminalOutput().print(" path [direct|clear|hops separated by spaces or commas]\r\n");
terminalOutput().print(" send <text>\r\n");
terminalOutput().print(" login <admin-password>\r\n");
terminalOutput().print(" cmd <remote-command>\r\n");
terminalOutput().print(" trace [recipient name or prefix]\r\n");
terminalOutput().print(" trace path <1|2|4> <prefixes...>\r\n");
terminalOutput().print(" advert\r\n");
terminalOutput().print(" reset path\r\n");
terminalOutput().print(" public <text>\r\n");
terminalOutput().print(" channels\r\n");
terminalOutput().print(" channel <name-or-slot> <text>\r\n");
// Keep identical help text in bounded chunks. Fewer virtual Print calls
// leave room for USB controls on small-flash boards without cutting CLI.
terminalOutput().print(
" get radio.rxps\r\n"
" get radio.rxps.config\r\n"
" set radio.rxps <off|on|level 1-10 [preamble 16|32]|rx_us sleep_us>\r\n"
" get radio.cad\r\n"
" set radio.cad <on|off>\r\n"
" set radio.cad timings <scan_ms|auto> <retry_ms|auto> <max_ms|auto>\r\n");
terminalOutput().print(
" get radio.rxgain\r\n"
" set radio.rxgain <on|off>\r\n"
" get radio.fem.rxgain\r\n"
" set radio.fem.rxgain <on|off>\r\n"
" get radio.fem.txgain\r\n"
" set radio.fem.txgain <on|off>\r\n");
terminalOutput().print(
" get/set flood.retry.count <0-15>\r\n"
" get/set flood.retry.path <0-63|off>\r\n"
" get/set flood.retry.group.path <0-63|off>\r\n"
" get/set flood.retry.advert <on|off>\r\n"
" card\r\n"
" import <meshcore://card>\r\n"
" clock\r\n"
" time <epoch-seconds>\r\n");
terminalOutput().print(
" list [n]\r\n"
" show [adverts|channels|emergency] [on|off]\r\n"
" to [recipient name or prefix]\r\n"
" path [direct|clear|hops separated by spaces or commas]\r\n"
" send <text>\r\n"
" login <admin-password>\r\n"
" cmd <remote-command>\r\n");
terminalOutput().print(
" trace [recipient name or prefix]\r\n"
" trace path <1|2|4> <prefixes...>\r\n"
" advert\r\n"
" reset path\r\n"
" public <text>\r\n"
" channels\r\n"
" channel <name-or-slot> <text>\r\n");
#if defined(NRF52_PLATFORM) && defined(EXTRAFS) && !defined(QSPIFLASH)
terminalOutput().print(" repair extrafs (erases/rebuilds internal ExtraFS)\r\n");
terminalOutput().print(" scan extrafs erase (USB only; reboot for destructive page test)\r\n");
@@ -9255,8 +9351,7 @@ void MyMesh::handleTerminalCommand(char* command) {
terminalOutput().print(" ota {status|ls|announce|folder|config|...}\r\n");
#endif
#endif
terminalOutput().print(" reboot\r\n");
terminalOutput().print(" ver\r\n");
terminalOutput().print(" reboot\r\n" " ver\r\n");
#if defined(NRF52_PLATFORM)
terminalOutput().println(" uf2reset");
#endif
@@ -10153,6 +10248,18 @@ bool MyMesh::advert() {
}
// To check if there is pending work
bool MyMesh::canRecoverUsbLogging() const {
// Do not discard dirty pages or interrupt a protocol/flash/radio operation.
// Normal lazy writes resolve this veto without adding writes to each poll.
return dirty_contacts_expiry == 0 && !_store->hasPendingContactWrites()
&& !hasOutbound() && !isAnyTempRadioActive() && !hasPendingOtaApply()
&& !command_radio_apply_pending && !saved_radio_apply_pending
&& _scheduled_reboot_at == 0 && pending_serial_reply_route == NULL
&& !binary_trace_pending && sign_data == NULL
&& !(_serial && _serial->hasPendingIO())
&& !(_iter_started && _serial && _serial->isConnected());
}
bool MyMesh::hasPendingWork() const {
#if COMPANION_FEATURE_NOTIFICATIONS
if (_notifications.active()) return true;
+15 -1
View File
@@ -49,6 +49,7 @@
#include <helpers/StaticPoolPacketManager.h>
#include <helpers/TerminalCommandTracker.h>
#include <helpers/TerminalDisplayFilter.h>
#include <helpers/UsbLogging.h>
#include <target.h>
#if defined(OTA_SHARED_COMPANION_QUEUE)
#include <helpers/BorrowableFrameBuffer.h>
@@ -350,6 +351,9 @@ protected:
bool sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pkt, uint32_t delay_millis=0) override;
void logRxRaw(float snr, float rssi, const uint8_t raw[], int len) override;
#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT
const char* getLogDateTime() override;
#endif
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
void logRx(mesh::Packet* packet, int len, float score) override;
void logTx(mesh::Packet* packet, int len) override;
@@ -418,9 +422,18 @@ protected:
public:
bool savePrefs() {
const uint8_t previous_usb_debug = _prefs.usb_debug_enabled;
_prefs.usb_debug_enabled = _prefs.usb_debug_enabled == 1 ? 1 : 0;
const bool saved =
_store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon);
if (saved) _prefs.clearDirty();
if (saved) {
_prefs.clearDirty();
#if MESH_USB_LOGGING_AVAILABLE
mesh::setUsbDebugEnabled(_prefs.usb_debug_enabled != 0);
#endif
} else {
_prefs.usb_debug_enabled = previous_usb_debug;
}
return saved;
}
#if COMPANION_FEATURE_READER
@@ -440,6 +453,7 @@ public:
// To check if there is pending work
bool hasPendingWork() const;
bool canRecoverUsbLogging() const;
private:
// Only snapshot the changed value: CompanionNodePrefs owns self-referencing
+30
View File
@@ -110,6 +110,9 @@ public:
uint8_t one_key_dm_enabled = 0;
// Appended after all existing fields so older images keep Bluetooth enabled.
uint8_t bluetooth_enabled = 1;
// Debug verbosity is independent of the packet/output master switch. Keep
// this after every prior persisted field so legacy images default to quiet.
uint8_t usb_debug_enabled = 0;
private:
class RadioPrefs : public CommonRadioPrefs {
@@ -134,6 +137,7 @@ private:
def("agc_int", _parent->agc_reset_interval);
def("hash_mode", _parent->path_hash_mode);
def("multi_ack", _parent->multi_acks);
def("usb_debug", _parent->usb_debug_enabled);
#ifdef TBEAM_1W
def("fan", _parent->fan_mode, sizeof(_parent->fan_mode));
def("fan_lo", _parent->fan_lo);
@@ -144,6 +148,32 @@ private:
public:
explicit RadioPrefs(CompanionNodePrefs* parent) : _parent(parent) { }
bool getByKey(const char* key, char* value, size_t max_len) override {
if (key != nullptr && strcmp(key, "usb_debug") == 0) {
if (value == nullptr || max_len == 0) return false;
if (max_len < 2) {
value[0] = 0;
return false;
}
value[0] = _parent->usb_debug_enabled == 1 ? '1' : '0';
value[1] = 0;
return true;
}
return CommonRadioPrefs::getByKey(key, value, max_len);
}
bool setByKey(const char* key, const char* value) override {
if (key != nullptr && strcmp(key, "usb_debug") == 0) {
if (value == nullptr
|| (strcmp(value, "0") != 0 && strcmp(value, "1") != 0)) {
return false;
}
_parent->usb_debug_enabled = value[0] == '1' ? 1 : 0;
markDirty();
return true;
}
return CommonRadioPrefs::setByKey(key, value);
}
float getFreq() const override { return _parent->freq; }
void setFreq(float value) override {
_parent->freq = value;
+36
View File
@@ -1,6 +1,8 @@
#include <Arduino.h> // needed for PlatformIO
#include <helpers/ui/StartupScreen.h>
#include <helpers/ui/DisplayPowerSettings.h>
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <Mesh.h>
#include <helpers/BluetoothMac.h>
#include "MyMesh.h"
@@ -1158,6 +1160,10 @@ static void serviceUsbTerminal() {
return;
}
#endif
// On dual CDC hardware only CDC1 is the logging-reader endpoint.
if (!mesh::hasDedicatedUsbLoggingPort()
&& strlen(usb_terminal_line) == usb_terminal_line_len)
mesh::noteUsbLoggingStatsCommand(usb_terminal_line);
the_mesh.handleTerminalCommand(usb_terminal_line);
clearUsbTerminalLine();
#if MESH_USB_LOGGING_AVAILABLE
@@ -2866,6 +2872,7 @@ void setup() {
#error "need to define filesystem"
#endif
#ifdef DISPLAY_CLASS
// Load only the display policy before radio and secondary-storage startup.
FILESYSTEM* display_prefs_fs = store.getPrimaryFS();
@@ -3053,6 +3060,14 @@ void setup() {
// platforms have no separate port, so this is a harmless no-op there.
mesh::beginUsbLoggingPort();
#if defined(NRF52_PLATFORM)
mesh::loadUsbLoggingWatchdog(store.getPrimaryFS(), !store.isVolatilePrimaryFS(),
[]() -> uint32_t { return rtc_clock.getCurrentTime(); });
#else
mesh::loadUsbLoggingWatchdog(store.getPrimaryFS(), true,
[]() -> uint32_t { return rtc_clock.getCurrentTime(); });
#endif
#if defined(BLE_PIN_CODE)
// Keep stack registration available for a later USB `set bluetooth on`,
// without advertising even briefly when startup enables all interfaces.
@@ -3300,6 +3315,25 @@ void setup() {
#endif
}
static bool usbLoggingRecoverySafe(void*) {
if (board.isOTAUpdateRunning() || board.isRadioTestActive()
|| radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor()
|| !the_mesh.canRecoverUsbLogging()) return false;
#if defined(ENABLE_USB_INTERFACE)
#if COMPANION_FEATURE_USB_MOTA_SOURCE
if (usb_mota_mode) return false;
#endif
// The logging CDC may be separate, but USB re-enumeration/reboot affects the
// primary Companion connection too. Never reset a live Binary client.
if (!the_mesh.isTerminalMode()
&& (isUsbTerminalDataConnected() || usb_serial_interface.hasPendingIO())) return false;
const auto usb = mesh::usbLoggingStatus();
if (usb.reader_connected && !usb.stalled
&& (usb_terminal_line_len || usb_terminal_discard_line)) return false;
#endif
return true;
}
void loop() {
#if defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
if (pico_wifi_active && WiFi.status() != WL_CONNECTED
@@ -3394,6 +3428,7 @@ void loop() {
#endif
rtc_clock.tick();
board.loop();
if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot();
#ifdef TBEAM_1W
board.updateFanControl();
#endif
@@ -3409,6 +3444,7 @@ void loop() {
// Host sessions, live logging, and button activity still need service.
bool can_sleep = the_mesh.getNodePrefs()->powersaving_enabled
&& !the_mesh.hasPendingWork()
&& !mesh::isUsbLoggingWatchdogArmed()
&& !mesh::wireless::control().pending();
#if defined(ESP32_PLATFORM) && MESH_USB_LOGGING_AVAILABLE
// The native-USB-only light-sleep path below bypasses ESP32Board::sleep.
+6 -6
View File
@@ -2465,7 +2465,7 @@ void MyMesh::printRecentRepeatersSerial() {
return;
}
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
if (hasPendingSerialOutput()) {
mesh::usbConsolePort().printf("Err - USB output busy\r\n");
return;
@@ -5068,7 +5068,7 @@ void MyMesh::dumpLogFile() {
return;
}
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
if (hasPendingSerialOutput()) {
mesh::usbConsolePort().printf("Err - USB output busy\r\n");
return;
@@ -5092,7 +5092,7 @@ void MyMesh::dumpLogFile() {
#endif
}
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
bool MyMesh::hasPendingSerialOutput() const {
return serial_log_active || serial_log_eof_pending || serial_recent_next >= 0;
}
@@ -11786,7 +11786,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_command_output && _local_cli_output.owns(*_command_output)) reply[0] = 0;
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
if (sender_timestamp == 0 && serial_log_eof_pending
&& strcmp(reply, " EOF") == 0) reply[0] = 0;
#endif
@@ -12264,7 +12264,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender, char *
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_command_output && _local_cli_output.owns(*_command_output)) reply[0] = 0;
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
if (sender_timestamp == 0 && serial_log_eof_pending
&& strcmp(reply, " EOF") == 0) reply[0] = 0;
#endif
@@ -12281,7 +12281,7 @@ void MyMesh::loop() {
// Check radio FIRST to ensure we don't miss incoming packets
// MQTT processing runs in a separate FreeRTOS task on Core 0, so we don't call bridge.loop() here
mesh::Mesh::loop();
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
servicePendingSerialOutput();
#endif
_cli.loop();
+11 -2
View File
@@ -314,8 +314,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks
Stream* _web_terminal = nullptr;
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
#if defined(NRF52_PLATFORM)
File serial_log_dump{InternalFS};
#else
File serial_log_dump;
#endif
size_t serial_log_remaining = 0;
size_t serial_log_pending_size = 0;
char serial_log_pending[640];
@@ -1073,7 +1077,7 @@ public:
}
void dumpLogFile() override;
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
// Large local-only replies advance between radio service passes.
bool hasPendingSerialOutput() const;
void servicePendingSerialOutput();
@@ -1146,6 +1150,11 @@ public:
bool handleHostCliSerialReply(const char* command, char* reply);
#endif
void loop();
bool canRecoverUsbLogging() const {
return dirty_contacts_expiry == 0 && !hasOutbound() && !isAnyTempRadioActive()
&& !hasPendingOtaApply() && !saved_radio_apply_pending
&& !temp_radio_handoff_pending && _ota_update_at == 0;
}
#if MESH_ENABLE_FLOOD_RULE_ENGINE
bool allowTransportPacket(const mesh::Packet* packet, uint8_t context);
bool allowRadioProfileCross(const mesh::Packet* packet) override {
+34 -4
View File
@@ -3,6 +3,8 @@
#include <helpers/IdentityGeneration.h>
#include <helpers/ui/StartupScreen.h>
#include <helpers/ui/DisplayPowerSettings.h>
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
#endif
@@ -97,7 +99,7 @@ void setup() {
mesh::wireless::control().begin(infrastructure_wireless);
mesh::prepareUsbLoggingPort();
Serial.begin(115200);
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::beginUsbLoggingPort();
#endif
#if MESH_PACKET_LOGGING
@@ -177,7 +179,7 @@ void setup() {
radioinit_attempts = 0;
const uint32_t retry_started = millis();
while (millis() - retry_started < 60000UL) {
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::serviceUsbLoggingPort();
mesh::serviceUsbTerminalPort();
#endif
@@ -278,6 +280,14 @@ void setup() {
the_mesh.begin(fs);
#if defined(NRF52_PLATFORM)
mesh::loadUsbLoggingWatchdog(fs, !volatile_primary_fs,
[]() -> uint32_t { return rtc_clock.getCurrentTime(); });
#else
mesh::loadUsbLoggingWatchdog(fs, true,
[]() -> uint32_t { return rtc_clock.getCurrentTime(); });
#endif
#if defined(NRF52_PLATFORM)
if (volatile_primary_fs) {
strncpy(the_mesh.getNodePrefs()->node_name,
@@ -314,7 +324,7 @@ void setup() {
static void __attribute__((noinline)) serviceCommandInterfaces() {
bool usb_ready = true;
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::serviceUsbLoggingPort();
mesh::serviceUsbTerminalPort();
if (mesh::takeUsbTerminalSessionReset()) {
@@ -374,6 +384,8 @@ static void __attribute__((noinline)) serviceCommandInterfaces() {
console.print('\n');
char reply[160];
reply[0] = 0;
if (strlen(command) == static_cast<size_t>(len))
mesh::noteUsbLoggingStatsCommand(command);
#if defined(MESH_SOAK_DIAGNOSTICS)
if (mesh::hil::handleSoakCommand(command, reply, sizeof(reply))) {
console.print(" -> ");
@@ -428,7 +440,23 @@ static void __attribute__((noinline)) serviceCommandInterfaces() {
#endif
}
static bool usbLoggingRecoverySafe(void*) {
if (board.isOTAUpdateRunning() || board.isRadioTestActive()
|| radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor()
|| !the_mesh.canRecoverUsbLogging()) return false;
const auto usb = mesh::usbLoggingStatus();
// A stale line/listing from a disconnected or stalled host cannot lock out
// recovery forever. Protect a functional live command pump, not diagnostics.
return !usb.reader_connected || usb.stalled
|| (!command[0] && !command_overflow
#if MESH_USB_CONSOLE_COOPERATIVE
&& !the_mesh.hasPendingSerialOutput()
#endif
);
}
void loop() {
mesh::serviceUsbLoggingPort();
mesh::wireless::control().service(millis());
#if defined(NRF52_PLATFORM)
board.feedWatchdog(the_mesh.getNodePrefs()->system_watchdog_enabled != 0);
@@ -456,7 +484,8 @@ void loop() {
if (display_ready) ui_task.loop();
#endif
rtc_clock.tick();
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot();
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::serviceUsbTerminalPort();
#endif
@@ -469,6 +498,7 @@ void loop() {
#endif
bool can_power_save = the_mesh.getNodePrefs()->powersaving_enabled
&& !board.isUsbDataConnected()
&& !mesh::isUsbLoggingWatchdogArmed()
&& !mesh::wireless::control().pending();
#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP \
+4 -4
View File
@@ -1645,7 +1645,7 @@ void MyMesh::dumpLogFile() {
return;
}
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
if (hasPendingSerialOutput()) {
mesh::usbConsolePort().printf("Err - USB output busy\r\n");
return;
@@ -1669,7 +1669,7 @@ void MyMesh::dumpLogFile() {
#endif
}
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
bool MyMesh::hasPendingSerialOutput() const {
return serial_log_active || serial_log_eof_pending;
}
@@ -2513,7 +2513,7 @@ void MyMesh::handleCommand(uint32_t sender_timestamp, char *command, char *reply
#if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED)
if (_command_output && _local_cli_output.owns(*_command_output)) reply[0] = 0;
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
if (sender_timestamp == 0 && serial_log_eof_pending
&& strcmp(reply, " EOF") == 0) reply[0] = 0;
#endif
@@ -2534,7 +2534,7 @@ void MyMesh::loop() {
// Check radio FIRST to ensure we don't miss incoming packets
// MQTT processing can take time, so we prioritize radio reception
mesh::Mesh::loop();
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
servicePendingSerialOutput();
#endif
_cli.loop();
+11 -2
View File
@@ -177,8 +177,12 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks,
Stream* _web_terminal = nullptr;
#endif
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
#if defined(NRF52_PLATFORM)
File serial_log_dump{InternalFS};
#else
File serial_log_dump;
#endif
size_t serial_log_remaining = 0;
size_t serial_log_pending_size = 0;
char serial_log_pending[640];
@@ -488,7 +492,7 @@ public:
}
void dumpLogFile() override;
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
// Large local-only replies advance between radio service passes.
bool hasPendingSerialOutput() const;
void servicePendingSerialOutput();
@@ -550,6 +554,11 @@ public:
return mesh::wireless::WiFi;
}
void loop();
bool canRecoverUsbLogging() const {
return dirty_contacts_expiry == 0 && !hasOutbound() && !isAnyTempRadioActive()
&& !hasPendingOtaApply() && !saved_radio_apply_pending
&& set_radio_at == 0 && _ota_update_at == 0;
}
#if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE
bool allowTransportPacket(const mesh::Packet* packet, uint8_t context);
bool allowRadioProfileCross(const mesh::Packet* packet) override {
+32 -3
View File
@@ -3,6 +3,8 @@
#include <helpers/IdentityGeneration.h>
#include <helpers/ui/StartupScreen.h>
#include <helpers/ui/DisplayPowerSettings.h>
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
#if MESH_PACKET_LOGGING
#include <helpers/SerialPacketLog.h>
#endif
@@ -53,7 +55,7 @@ void setup() {
mesh::wireless::control().begin(infrastructure_wireless);
mesh::prepareUsbLoggingPort();
Serial.begin(115200);
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::beginUsbLoggingPort();
#endif
#if MESH_PACKET_LOGGING
@@ -184,6 +186,14 @@ void setup() {
the_mesh.begin(fs);
#if defined(NRF52_PLATFORM)
mesh::loadUsbLoggingWatchdog(fs, !volatile_primary_fs,
[]() -> uint32_t { return rtc_clock.getCurrentTime(); });
#else
mesh::loadUsbLoggingWatchdog(fs, true,
[]() -> uint32_t { return rtc_clock.getCurrentTime(); });
#endif
#if defined(NRF52_PLATFORM)
if (volatile_primary_fs) {
strncpy(the_mesh.getNodePrefs()->node_name,
@@ -218,13 +228,27 @@ void setup() {
board.onBootComplete();
}
static bool usbLoggingRecoverySafe(void*) {
if (board.isOTAUpdateRunning() || board.isRadioTestActive()
|| radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor()
|| !the_mesh.canRecoverUsbLogging()) return false;
const auto usb = mesh::usbLoggingStatus();
return !usb.reader_connected || usb.stalled
|| (!command[0]
#if MESH_USB_CONSOLE_COOPERATIVE
&& !the_mesh.hasPendingSerialOutput()
#endif
);
}
void loop() {
mesh::serviceUsbLoggingPort();
mesh::wireless::control().service(millis());
#if defined(NRF52_PLATFORM)
board.feedWatchdog(the_mesh.getNodePrefs()->system_watchdog_enabled != 0);
#endif
bool usb_ready = true;
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::serviceUsbLoggingPort();
mesh::serviceUsbTerminalPort();
if (mesh::takeUsbTerminalSessionReset()) {
@@ -261,6 +285,9 @@ void loop() {
command[len - 1] = 0; // replace newline with C string null terminator
char reply[160];
reply[0] = 0;
if (len < static_cast<int>(sizeof(command) - 1)
&& strlen(command) == static_cast<size_t>(len - 1))
mesh::noteUsbLoggingStatsCommand(command);
#ifdef ETHERNET_ENABLED
if (!ethernet_handle_command(command, reply)) {
the_mesh.handleUsbCommand(command, reply);
@@ -299,7 +326,8 @@ void loop() {
#endif
rtc_clock.tick();
board.loop();
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot();
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::serviceUsbTerminalPort();
#endif
#ifdef TBEAM_1W
@@ -310,6 +338,7 @@ void loop() {
#endif
bool can_power_save = the_mesh.getNodePrefs()->powersaving_enabled
&& !board.isUsbDataConnected()
&& !mesh::isUsbLoggingWatchdogArmed()
&& !mesh::wireless::control().pending();
#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP \
+1 -1
View File
@@ -963,7 +963,7 @@ void SensorMesh::onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_i
bool SensorMesh::handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t* data, uint8_t flags, size_t len) {
MESH_DEBUG_PRINT("handleIncomingMsg: unhandled msg from ");
#ifdef MESH_DEBUG
if (mesh::isUsbLoggingEnabled()) {
if (mesh::isUsbDebugLoggingEnabled()) {
Stream& logging_port = mesh::usbLoggingPort();
mesh::Utils::printHex(logging_port, from.id.pub_key, PUB_KEY_SIZE);
logging_port.printf(": %s\n", data);
+5
View File
@@ -58,6 +58,11 @@ public:
SensorMesh(mesh::MainBoard& board, mesh::Radio& radio, mesh::MillisecondClock& ms, mesh::RNG& rng, mesh::RTCClock& rtc, mesh::MeshTables& tables);
void begin(FILESYSTEM* fs);
void loop();
bool canRecoverUsbLogging() const {
return dirty_contacts_expiry == 0 && !hasOutbound() && !isAnyTempRadioActive()
&& !hasPendingOtaApply() && !saved_radio_apply_pending
&& set_radio_at == 0;
}
void handleCommand(uint32_t sender_timestamp, char* command, char* reply,
int gpio_client_index = -1,
uint8_t gpio_path_hash_size = 1);
+28 -4
View File
@@ -7,6 +7,8 @@
#include <helpers/nrf52/RamFallbackFileSystem.h>
#endif
#include <helpers/UsbLogging.h>
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
#if defined(ESP32_PLATFORM)
#include <helpers/ESP32TrueRandom.h>
@@ -73,7 +75,7 @@ static const unsigned long POWERSAVING_FIRST_SLEEP_SECS = 120;
void setup() {
mesh::prepareUsbLoggingPort();
Serial.begin(115200);
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::beginUsbLoggingPort();
#endif
board.begin();
@@ -209,6 +211,14 @@ void setup() {
the_mesh.begin(fs);
#if defined(NRF52_PLATFORM)
mesh::loadUsbLoggingWatchdog(fs, !volatile_primary_fs,
[]() -> uint32_t { return rtc_clock.getCurrentTime(); });
#else
mesh::loadUsbLoggingWatchdog(fs, true,
[]() -> uint32_t { return rtc_clock.getCurrentTime(); });
#endif
#if defined(NRF52_PLATFORM)
if (volatile_primary_fs) {
strncpy(the_mesh.getNodePrefs()->node_name,
@@ -233,12 +243,21 @@ void setup() {
board.onBootComplete();
}
static bool usbLoggingRecoverySafe(void*) {
if (board.isOTAUpdateRunning() || board.isRadioTestActive()
|| radio_driver.isWatchdogObserving() || radio_driver.isCalibratingNoiseFloor()
|| !the_mesh.canRecoverUsbLogging()) return false;
const auto usb = mesh::usbLoggingStatus();
return !usb.reader_connected || usb.stalled || !command[0];
}
void loop() {
mesh::serviceUsbLoggingPort();
#if defined(NRF52_PLATFORM)
board.feedWatchdog(the_mesh.getNodePrefs()->system_watchdog_enabled != 0);
#endif
bool usb_ready = true;
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::serviceUsbLoggingPort();
mesh::serviceUsbTerminalPort();
if (mesh::takeUsbTerminalSessionReset()) command[0] = 0;
@@ -271,6 +290,9 @@ void loop() {
command[len - 1] = 0; // replace newline with C string null terminator
char reply[160];
reply[0] = 0;
if (len < static_cast<int>(sizeof(command) - 1)
&& strlen(command) == static_cast<size_t>(len - 1))
mesh::noteUsbLoggingStatsCommand(command);
the_mesh.handleCommand(0, command, reply); // NOTE: there is no sender_timestamp via serial!
if (reply[0]) {
console.print(" -> ");
@@ -288,7 +310,8 @@ void loop() {
if (display_ready) ui_task.loop();
#endif
rtc_clock.tick();
#if MESH_ESP32_USB_CONSOLE_COOPERATIVE
if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) board.reboot();
#if MESH_USB_CONSOLE_COOPERATIVE
mesh::serviceUsbTerminalPort();
#endif
#ifdef HAS_EXTERNAL_WATCHDOG
@@ -296,7 +319,8 @@ void loop() {
#endif
bool can_power_save = the_mesh.getNodePrefs()->powersaving_enabled
&& !board.isUsbDataConnected();
&& !board.isUsbDataConnected()
&& !mesh::isUsbLoggingWatchdogArmed();
#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) \
&& MOMENTARY_BUTTON_WAKE_FROM_SLEEP \
&& defined(PIN_USER_BTN) && defined(DISPLAY_CLASS)
+1 -1
View File
@@ -1232,7 +1232,7 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
break;
}
// MGR1 is a plaintext management extension, not an encrypted channel
// MGR1/MGR2/MGR3 are plaintext management extensions, not encrypted channel
// datagram. Keep the legacy group payload length shape for opaque relay
// compatibility. Recognize it before any channel decryption/delivery.
if (pkt->getPayloadType() == PAYLOAD_TYPE_GRP_DATA &&
+6 -6
View File
@@ -46,7 +46,7 @@ namespace mesh {
// overlapping debug formatter calls. The historical helper name
// is retained for callers; ESP32 TinyUSB uses the same bounded formatter.
inline size_t nrf52DebugPrintf(const char* format, ...) {
if (format == nullptr || !isUsbLoggingEnabled()) return 0;
if (format == nullptr || !isUsbDebugLoggingEnabled()) return 0;
static std::atomic_flag writer_busy = ATOMIC_FLAG_INIT;
if (writer_busy.test_and_set(std::memory_order_acquire)) return 0;
@@ -90,8 +90,8 @@ inline size_t nrf52DebugPrintf(const char* format, ...) {
#define MESH_DEBUG_PRINT(F, ...) do { mesh::nrf52DebugPrintf("DEBUG: " F, ##__VA_ARGS__); } while(0)
#define MESH_DEBUG_PRINTLN(F, ...) do { mesh::nrf52DebugPrintf("DEBUG: " F "\n", ##__VA_ARGS__); } while(0)
#else
#define MESH_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F, ##__VA_ARGS__); } } while(0)
#define MESH_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F "\n", ##__VA_ARGS__); } } while(0)
#define MESH_DEBUG_PRINT(F, ...) do { if (mesh::isUsbDebugLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F, ##__VA_ARGS__); } } while(0)
#define MESH_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbDebugLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("DEBUG: " F "\n", ##__VA_ARGS__); } } while(0)
#endif
#else
#define MESH_DEBUG_PRINT(...) {}
@@ -102,7 +102,7 @@ inline size_t nrf52DebugPrintf(const char* format, ...) {
#if defined(NRF52_PLATFORM) || MESH_ESP32_USB_CONSOLE_COOPERATIVE
#define BRIDGE_DEBUG_PRINTLN(F, ...) do { mesh::nrf52DebugPrintf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } while(0)
#else
#define BRIDGE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } } while(0)
#define BRIDGE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbDebugLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("%s BRIDGE: " F, getLogDateTime(), ##__VA_ARGS__); } } while(0)
#endif
#else
#define BRIDGE_DEBUG_PRINTLN(...) {}
@@ -114,8 +114,8 @@ inline size_t nrf52DebugPrintf(const char* format, ...) {
#define POWERSAVING_DEBUG_PRINT(F, ...) do { mesh::nrf52DebugPrintf("POWERSAVING: " F, ##__VA_ARGS__); } while(0)
#define POWERSAVING_DEBUG_PRINTLN(F, ...) do { mesh::nrf52DebugPrintf("POWERSAVING: " F "\n", ##__VA_ARGS__); } while(0)
#else
#define POWERSAVING_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F, ##__VA_ARGS__); } } while(0)
#define POWERSAVING_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F "\n", ##__VA_ARGS__); } } while(0)
#define POWERSAVING_DEBUG_PRINT(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F, ##__VA_ARGS__); } } while(0)
#define POWERSAVING_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("POWERSAVING: " F "\n", ##__VA_ARGS__); } } while(0)
#endif
#else
#define POWERSAVING_DEBUG_PRINT(...) {}
+1 -1
View File
@@ -24,7 +24,7 @@
#ifdef MQTT_DEBUG
#include <Arduino.h>
#define ALERT_DEBUG_PRINTLN(...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("Alert: " __VA_ARGS__); mesh::usbLoggingPort().println(); } } while (0)
#define ALERT_DEBUG_PRINTLN(...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("Alert: " __VA_ARGS__); mesh::usbLoggingPort().println(); } } while (0)
#else
#define ALERT_DEBUG_PRINTLN(...) do {} while (0)
#endif
+50 -1
View File
@@ -1,6 +1,7 @@
#include <Arduino.h>
#include "CommonCLI.h"
#include "PrefsSaveReplyGuard.h"
#include "UsbLoggingWatchdog.h"
#include <helpers/ui/DisplayPowerSettings.h>
#include "CLICommandUtils.h"
#include "FloodAdvertCLI.h"
@@ -762,6 +763,7 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
_prefs->system_watchdog_enabled = 1;
memset(_prefs->extra_sf, 0, sizeof(_prefs->extra_sf));
_prefs->usb_logging_enabled = 1;
_prefs->usb_debug_enabled = 0;
#ifdef WITH_RS232_BRIDGE
_prefs->bridge_uart = WITH_RS232_BRIDGE_UART;
#else
@@ -903,6 +905,7 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) {
if (loaded) syncOtaConfigFromPrefs(); // persisted OTA policy/keys -> OtaContext (else keep safe defaults)
#endif
#if MESH_USB_LOGGING_AVAILABLE
mesh::setUsbDebugEnabled(_prefs->usb_debug_enabled != 0);
mesh::setUsbLoggingEnabled(_prefs->usb_logging_enabled != 0);
#endif
_radio_profiles.adoptPrimaryPreamble(_prefs->primary_radio_preamble);
@@ -932,6 +935,9 @@ void CommonCLI::syncOtaConfigFromPrefs() {
#endif
void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
// Older or truncated preference images must never inherit debug opt-in
// from a previous load of a newer image.
_prefs->usb_debug_enabled = 0;
#if defined(RP2040_PLATFORM)
File file = fs->open(filename, "r");
#else
@@ -1321,6 +1327,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
if (file.available() >= (int)sizeof(_prefs->espnow_bridge_enabled)) {
file.read((uint8_t *)&_prefs->espnow_bridge_enabled,
sizeof(_prefs->espnow_bridge_enabled));
if (file.available() >= (int)sizeof(_prefs->usb_debug_enabled)) {
file.read((uint8_t *)&_prefs->usb_debug_enabled,
sizeof(_prefs->usb_debug_enabled));
}
}
}
}
@@ -1433,6 +1443,8 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean
_prefs->radio_fem_txgain = constrain(_prefs->radio_fem_txgain, 0, 1); // boolean
_prefs->usb_logging_enabled = constrain(_prefs->usb_logging_enabled, 0, 1); // boolean
// Only a deliberately saved 1 enables diagnostics; corrupted bytes are OFF.
_prefs->usb_debug_enabled = _prefs->usb_debug_enabled == 1 ? 1 : 0;
_prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean
if (!directRetryPrefsValid(_prefs)) {
setDefaultDirectRetryPrefs(_prefs);
@@ -1645,6 +1657,7 @@ static bool writeCommonPrefsImage(Writer& writer, NodePrefs* prefs) {
WRITE_COMMON_PREFS(&prefs->bridge_format); // 863
WRITE_COMMON_PREFS(&prefs->primary_radio_preamble); // appended primary tuple field
WRITE_COMMON_PREFS(&prefs->espnow_bridge_enabled); // appended Full ESP-NOW intent
WRITE_COMMON_PREFS(&prefs->usb_debug_enabled); // appended USB debug intent
#undef WRITE_COMMON_PREFS_BYTES
#undef WRITE_COMMON_PREFS
@@ -1657,6 +1670,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) {
if (plan.common) {
_common_save_result_known = true;
_common_save_succeeded = false;
_prefs->usb_debug_enabled = _prefs->usb_debug_enabled == 1 ? 1 : 0;
}
#ifdef WITH_MQTT_BRIDGE
// Observer builds use a verified temp/backup transaction for common prefs.
@@ -1666,7 +1680,6 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) {
_observer_save_result_known = true;
_observer_save_succeeded = saveMQTTPrefs(fs);
}
return;
#else
// Observer-only saves are a no-op on roles with no observer preference image.
if (!plan.common) return;
@@ -1803,6 +1816,7 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) {
file.write((uint8_t *)&_prefs->bridge_format, sizeof(_prefs->bridge_format)); // 863
file.write((uint8_t *)&_prefs->primary_radio_preamble, sizeof(_prefs->primary_radio_preamble)); // appended
file.write((uint8_t *)&_prefs->espnow_bridge_enabled, sizeof(_prefs->espnow_bridge_enabled)); // appended
file.write((uint8_t *)&_prefs->usb_debug_enabled, sizeof(_prefs->usb_debug_enabled)); // appended
_common_save_succeeded = file.commit();
if (!_common_save_succeeded) {
@@ -1810,6 +1824,14 @@ void CommonCLI::savePrefs(FILESYSTEM* fs, PrefsSaveRouting::Scope scope) {
}
}
#endif
#if MESH_USB_LOGGING_AVAILABLE
if (plan.common && _common_save_succeeded) {
// Dynamic config imports use this path too. Do not enable diagnostics
// until the corresponding common preference image is committed.
mesh::setUsbDebugEnabled(_prefs->usb_debug_enabled != 0);
mesh::setUsbLoggingEnabled(_prefs->usb_logging_enabled != 0);
}
#endif
}
#ifdef WITH_MQTT_BRIDGE
@@ -2599,6 +2621,7 @@ uint8_t CommonCLI::buildAdvertData(uint8_t node_type, uint8_t* app_data) {
void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* reply) {
PrefsSaveReplyGuard save_reply(_prefs_save_failures, reply);
mesh::cli::normalizeCommandVerb(command);
if (mesh::handleUsbLoggingWatchdogCommand(command, reply, 160)) return;
if (handleManagementCommand(command, reply)) return;
if (mesh::wireless::control().handle(command, reply, 160, millis(),
_callbacks->wirelessCommandSource(sender_timestamp))) return;
@@ -3398,6 +3421,27 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
}
return;
}
if (strncmp(config, "usb.debug", 9) == 0
&& (config[9] == 0 || config[9] == ' ' || config[9] == '\t')) {
const char* value = &config[9];
while (*value == ' ' || *value == '\t') value++;
bool enabled = false, reboot_if_needed = false;
if (mesh::cli::parseLoggingToggle(value, enabled, reboot_if_needed)
&& !reboot_if_needed) {
const uint8_t previous = _prefs->usb_debug_enabled;
_prefs->usb_debug_enabled = enabled ? 1 : 0;
if (!trySavePrefs()) {
_prefs->usb_debug_enabled = previous;
strcpy(reply, "Error: USB debug not saved; unchanged");
return;
}
mesh::setUsbDebugEnabled(enabled);
snprintf(reply, 160, "OK - USB debug %s (saved)", enabled ? "on" : "off");
} else {
strcpy(reply, "Error: usage set usb.debug on|off");
}
return;
}
#endif
#if MESH_USB_LOGGING_AVAILABLE && defined(WITH_MQTT_BRIDGE)
if (strncmp(config, "logging.output", 14) == 0
@@ -4686,6 +4730,11 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep
mesh::isUsbLoggingEnabled() ? "on" : "off");
return;
}
if (strcmp(config, "usb.debug") == 0) {
snprintf(reply, 160, "> %s",
mesh::isUsbDebugEnabled() ? "on" : "off");
return;
}
#endif
#if MESH_USB_LOGGING_AVAILABLE && defined(WITH_MQTT_BRIDGE)
if (strcmp(config, "logging.output") == 0) {
+4
View File
@@ -182,6 +182,9 @@ public:
// older images remain readable and merged standard builds keep the former
// logging artifact's enabled-at-first-boot behavior.
uint8_t usb_logging_enabled = 1;
// USB diagnostics are opt-in independently of the packet output gate.
// Persisted at the very end of /com_prefs, not beside usb_log.
uint8_t usb_debug_enabled = 0;
// Runtime UART choice for merged RS-232 repeater artifacts. Appended at
// /com_prefs offset 862; single-UART builds keep their compiled port here.
uint8_t bridge_uart = 0;
@@ -331,6 +334,7 @@ private:
def("secret", _parent->bridge_secret, sizeof(_parent->bridge_secret));
def("format", _parent->bridge_format);
def("usb_log", _parent->usb_logging_enabled);
def("usb_dbg", _parent->usb_debug_enabled);
}
public:
+1 -1
View File
@@ -48,7 +48,7 @@ static void s2v(const uint8_t key[32], const uint8_t* aad, size_t aad_len,
for (unsigned i = 0; i < 16; ++i) buf[len - 16 + i] ^= d[i];
cmac(aes, buf, len, tag);
} else {
dbl(d); memcpy(buf, data, len); buf[len] = 0x80;
dbl(d); if (len) memcpy(buf, data, len); buf[len] = 0x80;
for (unsigned i = 0; i < 16; ++i) buf[i] ^= d[i];
cmac(aes, buf, 16, tag);
}
+62 -9
View File
@@ -4,12 +4,20 @@
#include <stddef.h>
#include <string.h>
#include <math.h>
#include "UsbLoggingStatus.h"
// Portable management wire format. No update authorization is granted by this
// protocol. See docs/management_reports.md for the byte layout and trust model.
namespace mesh { namespace management {
constexpr size_t HEADER = 83, TAG = 16, ENTRY = 13, PER_PAGE = 6;
constexpr size_t MAX_KEYS = 36, MAX_PAGES = 6, MAX_PAYLOAD = HEADER + TAG + PER_PAGE * ENTRY;
constexpr size_t USB_STATUS_SIZE = 15, MGR2_HEADER = HEADER + USB_STATUS_SIZE;
constexpr size_t MGR2_PER_PAGE = 5;
constexpr size_t WATCHDOG_EVENT_OFFSET = MGR2_HEADER;
constexpr size_t CURRENT_HEADER = MGR2_HEADER + USB_WATCHDOG_EVENT_SIZE;
constexpr size_t CURRENT_PER_PAGE = 4;
constexpr size_t MAX_KEYS = 36, MAX_PAGES = 9;
constexpr size_t MAX_PAYLOAD = CURRENT_HEADER + TAG + CURRENT_PER_PAGE * ENTRY;
static_assert(MAX_PAYLOAD == 179, "MGR3 must fit the legacy padded payload budget");
constexpr uint32_t DAY = 86400, FLOOD_INTERVAL = 21 * DAY;
enum Feature : uint8_t { WIFI = 1, GPS = 2, NTP = 4, USB = 8, OTA = 16 };
enum Valid : uint16_t { FIRMWARE = 1, BOOTLOADER = 2, BASE = 4, STORE = 8,
@@ -95,21 +103,64 @@ bool seal(const uint8_t key[32], const uint8_t* aad, size_t aad_len,
bool open(const uint8_t key[32], const uint8_t* aad, size_t aad_len,
uint8_t* data, size_t len, const uint8_t tag[16]);
bool equal(const uint8_t* a, const uint8_t* b, size_t size);
inline size_t pageSize(const uint8_t* p) { return HEADER + p[82] * ENTRY + TAG; }
inline bool currentPage(const uint8_t* p) { return !memcmp(p, "MGR3", 4); }
inline bool usbPage(const uint8_t* p) { return currentPage(p) || !memcmp(p, "MGR2", 4); }
inline size_t headerSize(const uint8_t* p) {
return currentPage(p) ? CURRENT_HEADER : usbPage(p) ? MGR2_HEADER : HEADER;
}
inline size_t entriesPerPage(const uint8_t* p) {
return currentPage(p) ? CURRENT_PER_PAGE : usbPage(p) ? MGR2_PER_PAGE : PER_PAGE;
}
inline size_t pageSize(const uint8_t* p) { return headerSize(p) + p[82] * ENTRY + TAG; }
inline size_t floodSize(size_t canonical) { return 3 + ((canonical - 3 + 15) / 16) * 16; }
#if defined(STM32_PLATFORM) && defined(__GNUC__) && !defined(__clang__)
// The two Mesh callers use different padding modes. Keep one validator on
// flash-constrained STM32 targets instead of LTO specializing each call site.
__attribute__((noinline, noclone))
#endif
inline bool validPage(const uint8_t* p, size_t size, bool flood_padding = false) {
if (!p || size < HEADER + TAG || size > (flood_padding ? 179 : MAX_PAYLOAD) || memcmp(p, "MGR1", 4)) return false;
if (!p || size < HEADER + TAG || size > MAX_PAYLOAD) return false;
const uint32_t magic = read32(p);
if ((magic & 0xffffffu) != 0x52474du) return false; // exact MGR prefix
const unsigned version = (magic >> 24) - '1';
if (version > 2) return false;
const size_t minimum = version == 0 ? HEADER + TAG
: version == 1 ? MGR2_HEADER + TAG : CURRENT_HEADER + TAG;
const size_t per_page = PER_PAGE - version;
if (size < minimum) return false;
// Reserved bits occupy the upper flag byte and upper two stage bits.
if (version && (read16(p + HEADER + 1) & 0xc0f8u)) return false;
if (version == 2 && !validUsbWatchdogEvent(p + WATCHDOG_EVENT_OFFSET)) return false;
const unsigned page = p[78], pages = p[79], total = p[80], first = p[81], count = p[82];
const unsigned expected_pages = total ? (total + PER_PAGE - 1) / PER_PAGE : 1;
if (total > MAX_KEYS || pages != expected_pages || page >= pages || first != page * PER_PAGE || first > total) return false;
const unsigned expected_pages = total ? (total + per_page - 1) / per_page : 1;
if (total > MAX_KEYS || pages != expected_pages || page >= pages || first != page * per_page) return false;
// Canonical pages and page bounds imply first <= total (also for total 0).
const unsigned remaining = total - first;
if (count != (remaining < PER_PAGE ? remaining : PER_PAGE)) return false;
const size_t canonical = pageSize(p);
if (size != (flood_padding ? floodSize(canonical) : canonical)) return false;
if (count != (remaining < per_page ? remaining : per_page)) return false;
const size_t canonical = minimum + count * ENTRY;
// Exactly one length in [canonical, canonical + 15] is 3 modulo 16.
// Unsigned underflow also rejects lengths shorter than canonical.
const size_t padding = size - canonical;
if (padding > (flood_padding ? 15u : 0u) ||
(flood_padding && (size & 15u) != 3)) return false;
for (size_t i = canonical; i < size; ++i) if (p[i]) return false;
return true;
}
// MGR2/MGR3 public USB snapshot. The AES-SIV AAD authenticates every byte.
inline void encodeUsbStatus(uint8_t* p, const UsbLoggingStatus& status) {
const uint16_t flags = (status.supported ? 1u : 0u) |
(status.logging_enabled ? 2u : 0u) | (status.watchdog_enabled ? 4u : 0u) |
(status.host_connected ? 8u : 0u) | (status.reader_connected ? 16u : 0u) |
(status.stalled ? 32u : 0u) | (status.recovering ? 64u : 0u) |
(status.recovery_deferred ? 128u : 0u) | (status.persistence_ready ? 256u : 0u) |
(status.watchdog_auto ? 512u : 0u) | (status.logger_active ? 1024u : 0u);
write16(p, flags);
p[2] = (status.stage & 3u) | ((status.backoff_step & 15u) << 2);
write32(p + 3, status.retry_seconds); write32(p + 7, status.inactive_seconds);
write32(p + 11, status.auto_connected_seconds);
}
struct AclList {
uint8_t entries[MAX_KEYS][ENTRY] = {};
uint8_t count = 0;
@@ -120,6 +171,8 @@ struct AclList {
if (count == MAX_KEYS) return false;
memcpy(entries[count], token, 12); entries[count++][12] = permissions; return true;
}
uint8_t pages() const { return count ? (count + PER_PAGE - 1) / PER_PAGE : 1; }
uint8_t pages(size_t per_page = CURRENT_PER_PAGE) const {
return count ? (count + per_page - 1) / per_page : 1;
}
};
} }
+12 -8
View File
@@ -26,7 +26,7 @@ struct ManagementReporter::Working {
History history;
Extrema during_report;
AclList acl;
uint8_t header[HEADER] = {};
uint8_t header[CURRENT_HEADER] = {};
TransportKey scope;
uint8_t route_path[MAX_PATH_SIZE] = {};
uint8_t route_path_len = OUT_PATH_UNKNOWN;
@@ -103,7 +103,10 @@ uint32_t ManagementReporter::jitter() const {
}
void ManagementReporter::snapshot() {
auto& w = *work; memset(w.header, 0, sizeof(w.header)); w.acl = AclList();
auto* p = w.header; memcpy(p, "MGR1", 4); memcpy(p + 4, mesh.self_id.pub_key, 16);
auto* p = w.header; memcpy(p, "MGR3", 4); memcpy(p + 4, mesh.self_id.pub_key, 16);
const UsbLoggingStatus usb = usbLoggingStatus();
encodeUsbStatus(p + HEADER, usb);
encodeUsbWatchdogEvent(p + WATCHDOG_EVENT_OFFSET, usb.last_event);
write32(p + 20, sequence); write32(p + 24, mesh.getRTCClock()->getCurrentTime());
const uint64_t hours = uptime / 3600; write16(p + 60, hours > 65535 ? 65535 : hours);
w.history.week().encode(p + 62); w.history.period.encode(p + 66);
@@ -229,15 +232,16 @@ void ManagementReporter::start(bool flood) {
}
void ManagementReporter::sendPage() {
auto& w = *work;
uint8_t packet[MAX_PAYLOAD], enc[32]; memcpy(packet, w.header, HEADER);
const uint8_t first = w.page * PER_PAGE;
const uint8_t count = w.acl.count - first < PER_PAGE ? w.acl.count - first : PER_PAGE;
uint8_t packet[MAX_PAYLOAD], enc[32]; memcpy(packet, w.header, CURRENT_HEADER);
const uint8_t first = w.page * CURRENT_PER_PAGE;
const uint8_t count = w.acl.count - first < CURRENT_PER_PAGE ? w.acl.count - first : CURRENT_PER_PAGE;
packet[78] = w.page; packet[79] = w.acl.pages(); packet[80] = w.acl.count;
packet[81] = first; packet[82] = count;
const size_t private_len = count * ENTRY, size = HEADER + private_len + TAG;
memcpy(packet + HEADER, w.acl.entries + first, private_len);
const size_t private_len = count * ENTRY, size = CURRENT_HEADER + private_len + TAG;
memcpy(packet + CURRENT_HEADER, w.acl.entries + first, private_len);
deriveKey(key, "MeshCore-MGR1-SIV", packet + 4, enc);
seal(enc, packet, HEADER, packet + HEADER, private_len, packet + HEADER + private_len);
seal(enc, packet, CURRENT_HEADER, packet + CURRENT_HEADER, private_len,
packet + CURRENT_HEADER + private_len);
erase(enc, sizeof(enc));
Packet* pkt = mesh.createRawData(packet, size);
if (!pkt) { w.page_in = 60; return; }
+2 -1
View File
@@ -4,6 +4,7 @@
#include "MQTTConnectionPolicy.h"
#include "WifiPowerSavePolicy.h"
#include "UsbLogging.h"
#include <atomic>
#include <climits>
@@ -26,7 +27,7 @@ extern void tcpipInit();
// Same "MQTT: " prefix as MQTT_DEBUG_PRINTLN so existing log greps keep matching.
#if defined(MQTT_DEBUG)
#define NETWORK_DEBUG_PRINTLN(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F "\n", ##__VA_ARGS__); } } while (0)
#define NETWORK_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbDebugLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("MQTT: " F "\n", ##__VA_ARGS__); } } while (0)
#else
#define NETWORK_DEBUG_PRINTLN(...) do {} while (0)
#endif
+5 -2
View File
@@ -221,14 +221,17 @@ class BufferedNonBlockingWriteStream : public Stream {
return written;
}
void discardPending() {
if (_busy.test_and_set(std::memory_order_acquire)) return;
bool tryDiscardPending() {
if (_busy.test_and_set(std::memory_order_acquire)) return false;
_head = 0;
_tail = 0;
_count = 0;
_busy.clear(std::memory_order_release);
return true;
}
void discardPending() { (void)tryDiscardPending(); }
size_t queuedByteCount() {
if (_busy.test_and_set(std::memory_order_acquire)) return CAPACITY;
const size_t count = _count;
+302 -24
View File
@@ -1,4 +1,5 @@
#include "UsbLogging.h"
#include "UsbLoggingClientActivity.h"
#if defined(ARDUINO)
#include <Arduino.h>
@@ -17,12 +18,15 @@
#if MESH_ESP32_TINYUSB_NONBLOCKING
#include "esp32-hal-tinyusb.h"
#endif
#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB)
#include <Adafruit_TinyUSB.h>
#endif
#if MESH_ESP32_HWCDC_SESSION_GUARD
#include "UsbAsciiBinarySwitch.h"
#endif
#if defined(NRF52_PLATFORM) && \
(defined(ENABLE_USB_INTERFACE) || defined(OTA_FOLDER_SERIAL))
(defined(USE_TINYUSB) || defined(ENABLE_USB_INTERFACE) || defined(OTA_FOLDER_SERIAL))
#define MESH_NRF52_PRIMARY_USB_NONBLOCKING 1
#include <Adafruit_TinyUSB.h>
#if !defined(CFG_TUD_CDC) || CFG_TUD_CDC < 1
@@ -59,6 +63,23 @@ static std::atomic<bool> usb_logging_enabled{false};
static std::atomic<bool> usb_logging_enabled{true};
#endif
static std::atomic<bool> usb_logging_preference_known{false};
static std::atomic<bool> usb_debug_enabled{false};
static std::atomic<uint32_t> usb_logging_tx_progress{0};
static std::atomic<bool> usb_logging_tx_waiting{false};
#if MESH_ESP32_TINYUSB_NONBLOCKING \
|| (defined(NRF52_PLATFORM) && defined(USE_TINYUSB))
static bool usb_logging_watchdog_detached = false;
static uint32_t usb_logging_watchdog_attach_at = 0;
#endif
static void noteUsbLoggingTxAttempt() {
if (isUsbLoggingEnabled()) usb_logging_tx_waiting.store(true, std::memory_order_release);
}
static void noteUsbLoggingTxComplete() {
if (!isUsbLoggingEnabled()) return;
usb_logging_tx_progress.fetch_add(1, std::memory_order_acq_rel);
usb_logging_tx_waiting.store(false, std::memory_order_release);
}
class NullUsbLoggingStream : public Stream {
public:
@@ -100,12 +121,14 @@ static void handleEsp32TinyUsbEvent(void*, esp_event_base_t, int32_t event_id,
void* event_data) {
// This is the Arduino event task, not TinyUSB's owner. Only publish an
// epoch: queue cleanup and endpoint access remain in application service.
if (event_id == ARDUINO_USB_CDC_TX_EVENT) noteUsbLoggingTxComplete();
bool closed = event_id == ARDUINO_USB_CDC_DISCONNECTED_EVENT;
if (event_id == ARDUINO_USB_CDC_LINE_STATE_EVENT && event_data != nullptr) {
const auto* event = static_cast<const arduino_usb_cdc_event_data_t*>(event_data);
closed = !event->line_state.dtr;
}
if (closed) {
clearUsbLoggingClientActivity();
esp32_tinyusb_reset_generation.fetch_add(1, std::memory_order_acq_rel);
}
}
@@ -130,6 +153,7 @@ class Esp32TinyUsbFifoStream : public Stream {
const size_t available = tud_cdc_n_write_available(0);
const size_t attempt = size < available ? size : available;
if (attempt == 0) return 0;
noteUsbLoggingTxAttempt();
const size_t written = tud_cdc_n_write(0, data, attempt);
(void)tud_cdc_n_write_flush(0);
return written;
@@ -288,6 +312,7 @@ static UsbSelfResetBurstGuard esp32_hwcdc_self_reset_guard;
// remain parked until a later request happens to kick TX. Keep requesting that
// interrupt until the framework reports actual TX progress.
static std::atomic<bool> esp32_hwcdc_tx_kick_pending{false};
static std::atomic<bool> esp32_hwcdc_tx_primed{false};
static portMUX_TYPE esp32_hwcdc_session_mux = portMUX_INITIALIZER_UNLOCKED;
static uint32_t esp32_hwcdc_taken_bus_reset_generation = 0;
static bool esp32_hwcdc_event_handler_registered = false;
@@ -307,6 +332,7 @@ static bool canAccessEsp32Hwcdc(void*) {
static void handleEsp32HwcdcEvent(void*, esp_event_base_t, int32_t event_id,
void*) {
if (event_id == ARDUINO_HW_CDC_BUS_RESET_EVENT) {
clearUsbLoggingClientActivity();
// A host may issue several reset requests while enumerating the clean
// transport. Suppress the complete burst; post-clean traffic below ends
// the exemption before any later active-session reset can be ignored.
@@ -321,6 +347,7 @@ static void handleEsp32HwcdcEvent(void*, esp_event_base_t, int32_t event_id,
esp32_hwcdc_access_generation.fetch_add(
1, std::memory_order_acq_rel);
esp32_hwcdc_tx_kick_pending.store(false, std::memory_order_release);
esp32_hwcdc_tx_primed.store(false, std::memory_order_release);
usb_serial_jtag_ll_disable_intr_mask(
USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY);
portEXIT_CRITICAL(&esp32_hwcdc_session_mux);
@@ -336,6 +363,12 @@ static void handleEsp32HwcdcEvent(void*, esp_event_base_t, int32_t event_id,
// expected enumeration is over. A subsequent reset must not be ignored.
esp32_hwcdc_self_reset_guard.notePostCleanActivity();
}
if (event_id == ARDUINO_HW_CDC_TX_EVENT
&& esp32_hwcdc_tx_primed.exchange(true, std::memory_order_acq_rel)) {
// The first empty interrupt only stages the first packet. A subsequent
// one proves the host picked up the preceding IN transfer.
noteUsbLoggingTxComplete();
}
}
class Esp32HwcdcSessionStream : public Stream {
@@ -364,6 +397,7 @@ public:
size_t write(const uint8_t* data, size_t size) override {
if (data == nullptr || size == 0) return 0;
noteUsbLoggingTxAttempt();
esp32_hwcdc_tx_kick_pending.store(true, std::memory_order_release);
return Serial.write(data, size);
}
@@ -424,7 +458,7 @@ static void serviceEsp32HwcdcTxKick() {
#endif
#if MESH_NRF52_PRIMARY_USB_NONBLOCKING
#if defined(ENABLE_USB_INTERFACE)
#if defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)
// DTR or actual host input proves a CDC0 client. Stock MeshCLI deliberately
// deasserts DTR, so it cannot be a prerequisite for receiving its first frame.
// Access still requires the application to complete the current close epoch.
@@ -433,6 +467,7 @@ static void serviceEsp32HwcdcTxKick() {
// older epoch and can never reopen across a newer close.
static std::atomic<uint32_t> primary_usb_reset_generation{0};
static std::atomic<uint32_t> primary_usb_allowed_generation{0};
static std::atomic<bool> primary_usb_terminal_discard_pending{false};
static std::atomic<bool> primary_usb_line_state_dtr{false};
static std::atomic<uint32_t> primary_usb_rx_generation{UINT32_MAX};
static constexpr uint32_t primary_usb_session_settle_millis = 8;
@@ -443,6 +478,7 @@ static uint32_t primaryUsbSessionGeneration() {
}
static bool canAccessPrimaryUsbSession(void*) {
if (primary_usb_terminal_discard_pending.load(std::memory_order_acquire)) return false;
const uint32_t generation = primaryUsbSessionGeneration();
if (!tud_mounted()
|| primary_usb_allowed_generation.load(std::memory_order_acquire)
@@ -477,6 +513,9 @@ static void endPrimaryUsbHostSession(bool clear_cdc_fifos) {
millis() + primary_usb_session_settle_millis,
std::memory_order_release);
primary_usb_reset_generation.fetch_add(1, std::memory_order_acq_rel);
#if !defined(MESH_DUAL_CDC_LOGGING)
clearUsbLoggingClientActivity();
#endif
if (clear_cdc_fifos) {
// The line-state callback runs before TinyUSB resets the device. A device
// unmount/remount boundary has already reset these class FIFOs.
@@ -497,7 +536,7 @@ static size_t writeTinyUsbCdcOnce(void* context, const uint8_t* data,
if (data == nullptr || size == 0 || instance >= CFG_TUD_CDC) {
return 0;
}
#if defined(ENABLE_USB_INTERFACE)
#if defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)
if (instance == 0) {
if (!canAccessPrimaryUsbSession(nullptr)) return 0;
} else
@@ -509,12 +548,17 @@ static size_t writeTinyUsbCdcOnce(void* context, const uint8_t* data,
const size_t available = tud_cdc_n_write_available(instance);
const size_t attempt = size < available ? size : available;
if (attempt == 0) return 0;
#if defined(MESH_DUAL_CDC_LOGGING)
if (instance == 1) noteUsbLoggingTxAttempt();
#else
if (instance == 0) noteUsbLoggingTxAttempt();
#endif
return tud_cdc_n_write(instance, data, attempt);
}
static SingleAttemptNonBlockingStream nonblocking_primary_usb_companion_port(
Serial, writeTinyUsbCdcOnce, reinterpret_cast<void*>(uintptr_t{0})
#if defined(ENABLE_USB_INTERFACE)
#if defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)
, canAccessPrimaryUsbSession
#endif
);
@@ -524,7 +568,7 @@ static SingleAttemptNonBlockingStream nonblocking_primary_usb_companion_port(
static AtomicWholeRecordNonBlockingStream<11>
nonblocking_primary_usb_mota_port(
nonblocking_primary_usb_companion_port);
#if defined(ENABLE_USB_INTERFACE)
#if defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB)
// Unlike Binary Companion frames, a terminal reply is produced as many Print
// calls whose return values are not consumed by the CLI. Retain one complete
// multi-line response while a normal host drains CDC0, but bound the memory and
@@ -534,6 +578,30 @@ static BufferedNonBlockingWriteStream<4096>
nonblocking_primary_usb_companion_port);
static uint32_t primary_usb_terminal_seen_reset_generation = 0;
static uint32_t primary_usb_terminal_taken_reset_generation = 0;
// Functional replies and diagnostics on one CDC need one chronological queue
// so their unwritten suffixes cannot overtake each other. Diagnostics leave
// most capacity available for stats/configuration replies.
static constexpr size_t nrf52_usb_functional_reserve = 3072;
class Nrf52BufferedLoggingStream : public Stream {
public:
int available() override { return 0; }
int read() override { return -1; }
int peek() override { return -1; }
void flush() override { serviceUsbTerminalPort(); }
int availableForWrite() override {
const int free = buffered_primary_usb_terminal_port.availableForWrite();
return free > static_cast<int>(nrf52_usb_functional_reserve)
? free - nrf52_usb_functional_reserve : 0;
}
size_t write(uint8_t value) override { return write(&value, 1); }
size_t write(const uint8_t* data, size_t size) override {
if (!data || !size || size > 4096 || !canAccessPrimaryUsbSession(nullptr)
|| availableForWrite() < static_cast<int>(size)) return 0;
return buffered_primary_usb_terminal_port.write(data, size);
}
};
static Nrf52BufferedLoggingStream buffered_primary_usb_logging_port;
#endif
#endif
@@ -567,6 +635,7 @@ static void setPlatformDebugOutputEnabled(bool enabled) {
static bool dedicated_usb_logging_port_configured = false;
static bool dedicated_usb_logging_port_started = false;
static std::atomic<bool> dedicated_usb_logging_port_connected{false};
static std::atomic<bool> dedicated_usb_logging_watchdog_reset_requested{false};
static bool dedicated_usb_logging_sof_enabled = false;
static constexpr char dedicated_usb_logging_descriptor[] =
@@ -622,6 +691,7 @@ static size_t dedicated_usb_logging_identity_offset = 0;
// discard the identity marker and expose a later diagnostic as byte zero.
static constexpr uint8_t dedicated_usb_logging_host_settle_sofs = 50;
static uint8_t dedicated_usb_logging_quiet_sofs = 0;
static UsbLoggingStatsLineParser dedicated_usb_logging_stats_parser;
static void resetDedicatedUsbLoggingIdentity() {
dedicated_usb_logging_identity_offset = 0;
@@ -634,6 +704,9 @@ static void resetDedicatedUsbLoggingUsbTaskState() {
}
static void restartDedicatedUsbLoggingHostSession() {
clearUsbLoggingClientActivity();
dedicated_usb_logging_stats_parser.reset();
tud_cdc_n_read_flush(1);
// This callback and the SOF drain both run in TinyUSB's task. Gate producers
// before clearing both queues, then publish a generation so the next SOF
// repeats the reset after any producer which was already in flight. Clearing
@@ -682,6 +755,10 @@ static void handleDedicatedUsbLoggingLineCoding() {
// 1 kHz also catches a close/reopen that occurs entirely between main-loop
// service calls.
void serviceDedicatedUsbLoggingFromUsbTask() {
if (dedicated_usb_logging_watchdog_reset_requested.exchange(false,
std::memory_order_acq_rel)) {
restartDedicatedUsbLoggingHostSession();
}
const uint32_t reset_generation =
dedicated_usb_logging_reset_generation.load(std::memory_order_acquire);
if (reset_generation != dedicated_usb_logging_seen_reset_generation) {
@@ -699,6 +776,9 @@ void serviceDedicatedUsbLoggingFromUsbTask() {
false, std::memory_order_release);
}
if (!connected) {
if (dedicated_usb_logging_usb_task_connected)
clearUsbLoggingClientActivity();
dedicated_usb_logging_stats_parser.reset();
dedicated_usb_logging_port_connected.store(
false, std::memory_order_release);
resetDedicatedUsbLoggingUsbTaskState();
@@ -719,6 +799,16 @@ void serviceDedicatedUsbLoggingFromUsbTask() {
dedicated_usb_logging_port_connected.store(
true, std::memory_order_release);
// CDC1 is still logging-only, not a second configuration console. Consume
// bounded stats polling input solely to distinguish an application from
// a plugged cable/open port. Endpoint access stays in TinyUSB's owner.
for (size_t budget = 0; budget < 64 && tud_cdc_n_available(1); ++budget) {
uint8_t value;
if (tud_cdc_n_read(1, &value, 1) != 1) break;
if (dedicated_usb_logging_stats_parser.consume(static_cast<char>(value)))
noteUsbLoggingStatsCommand("stats-core");
}
if (!dedicated_usb_logging_usb_task_connected) {
// Drop diagnostics from the prior host before sending byte zero of the
// marker to the new host.
@@ -762,7 +852,11 @@ void serviceDedicatedUsbLoggingFromUsbTask() {
// Single-CDC nRF52 roles need the same protection. In particular, BLE debug
// callbacks and packet logging write through usbLoggingPort() without going
// through MeshCore's formatted-debug helper.
#if MESH_NRF52_PRIMARY_USB_NONBLOCKING
#if MESH_NRF52_PRIMARY_USB_NONBLOCKING && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
static WholeRecordNonBlockingStream<640>
nonblocking_primary_usb_logging_port(
buffered_primary_usb_logging_port);
#elif MESH_NRF52_PRIMARY_USB_NONBLOCKING
static AtomicWholeRecordNonBlockingStream<>
nonblocking_primary_usb_logging_port(
nonblocking_primary_usb_companion_port);
@@ -776,13 +870,26 @@ bool isUsbLoggingEnabled() {
return usb_logging_enabled.load(std::memory_order_relaxed);
}
bool isUsbDebugEnabled() {
return usb_debug_enabled.load(std::memory_order_relaxed);
}
void setUsbDebugEnabled(bool enabled) {
usb_debug_enabled.store(enabled, std::memory_order_relaxed);
setPlatformDebugOutputEnabled(isUsbDebugLoggingEnabled());
}
void setUsbLoggingEnabled(bool enabled) {
#if MESH_ESP32_TINYUSB_NONBLOCKING
const bool was_enabled = isUsbLoggingEnabled();
#endif
usb_logging_enabled.store(enabled, std::memory_order_relaxed);
if (!enabled) {
clearUsbLoggingClientActivity();
usb_logging_tx_waiting.store(false, std::memory_order_release);
}
usb_logging_preference_known.store(true, std::memory_order_relaxed);
setPlatformDebugOutputEnabled(enabled);
setPlatformDebugOutputEnabled(isUsbDebugLoggingEnabled());
#if MESH_ESP32_TINYUSB_NONBLOCKING
// Text shares one chronological queue. When leaving logging mode, discard
// its residual application bytes before later Binary/mOTA traffic can start.
@@ -825,8 +932,8 @@ void beginUsbLoggingPort() {
// setup() calls this once before role preferences are loaded and again
// afterwards. The first call silences framework diagnostics on a protected
// ESP32 Companion stream; setUsbLoggingEnabled() restores them only when the
// saved setting explicitly enables logging.
setPlatformDebugOutputEnabled(isUsbLoggingEnabled());
// saved settings explicitly enable both USB output and debug verbosity.
setPlatformDebugOutputEnabled(isUsbDebugLoggingEnabled());
#if MESH_ESP32_TINYUSB_NONBLOCKING
if (!esp32_tinyusb_event_handler_registered) {
Serial.onEvent(ARDUINO_USB_CDC_ANY_EVENT, handleEsp32TinyUsbEvent);
@@ -868,6 +975,16 @@ void beginUsbLoggingPort() {
}
void serviceUsbLoggingPort() {
#if MESH_ESP32_TINYUSB_NONBLOCKING \
|| (defined(NRF52_PLATFORM) && defined(USE_TINYUSB))
// A timed reconnect retains all descriptors and never blocks the radio loop
// in delay(), Serial.end(), or a host-progress wait.
if (usb_logging_watchdog_detached
&& int32_t(millis() - usb_logging_watchdog_attach_at) >= 0) {
(void)tud_connect();
usb_logging_watchdog_detached = false;
}
#endif
#if MESH_ESP32_TINYUSB_NONBLOCKING
serviceEsp32TinyUsbPorts();
#elif MESH_ESP32_HWCDC_SESSION_GUARD
@@ -912,6 +1029,9 @@ void serviceUsbLoggingPort() {
dedicated_usb_logging_sof_enabled = should_service;
}
#endif
#if MESH_NRF52_USB_CONSOLE_COOPERATIVE && !defined(ENABLE_USB_INTERFACE)
serviceUsbTerminalPort();
#endif
}
struct Esp32HwcdcPurgeResult {
@@ -989,6 +1109,7 @@ bool resetUsbCompanionTransport() {
esp32_hwcdc_access_generation.fetch_add(
1, std::memory_order_acq_rel) + 1U;
esp32_hwcdc_tx_kick_pending.store(false, std::memory_order_release);
esp32_hwcdc_tx_primed.store(false, std::memory_order_release);
usb_serial_jtag_ll_disable_intr_mask(
USB_SERIAL_JTAG_INTR_SERIAL_IN_EMPTY);
portEXIT_CRITICAL(&esp32_hwcdc_session_mux);
@@ -1037,7 +1158,7 @@ bool resetUsbCompanionTransport() {
esp32_hwcdc_cleanup_pending = false;
// A concurrent runtime preference change is authoritative; never restore a
// stale snapshot taken before the purge.
setPlatformDebugOutputEnabled(isUsbLoggingEnabled());
setPlatformDebugOutputEnabled(isUsbDebugLoggingEnabled());
#endif
return true;
}
@@ -1063,6 +1184,11 @@ Stream& usbLoggingPort() {
#endif
}
Stream& usbDebugPort() {
if (!isUsbDebugLoggingEnabled()) return null_usb_logging_stream;
return usbLoggingPort();
}
Stream& usbCompanionPort() {
#if MESH_ESP32_TINYUSB_NONBLOCKING
return nonblocking_esp32_tinyusb_port;
@@ -1075,7 +1201,7 @@ Stream& usbCompanionPort() {
#endif
}
#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
#if defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
bool isUsbCompanionClientConnected() {
return canAccessPrimaryUsbSession(nullptr);
}
@@ -1097,7 +1223,7 @@ Stream& usbTerminalPort(bool enabled) {
if (!enabled) return null_usb_logging_stream;
#if MESH_ESP32_TINYUSB_NONBLOCKING
return buffered_esp32_tinyusb_terminal_port;
#elif defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
#elif defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
return buffered_primary_usb_terminal_port;
#else
return usbCompanionPort();
@@ -1109,6 +1235,8 @@ Stream& usbConsolePort() {
return buffered_esp32_tinyusb_terminal_port;
#elif MESH_ESP32_HWCDC_SESSION_GUARD
return guarded_esp32_hwcdc_port;
#elif MESH_NRF52_USB_CONSOLE_COOPERATIVE
return buffered_primary_usb_terminal_port;
#else
return Serial;
#endif
@@ -1122,6 +1250,9 @@ bool canAcceptUsbConsoleCommand() {
// Leave enough room for a complete ordinary CLI response. Larger listings
// already retain and retry a short write from their cooperative pump.
return guarded_esp32_hwcdc_port.availableForWrite() >= 256;
#elif MESH_NRF52_USB_CONSOLE_COOPERATIVE
return buffered_primary_usb_terminal_port.availableForWrite()
>= static_cast<int>(nrf52_usb_functional_reserve);
#else
return true;
#endif
@@ -1130,13 +1261,18 @@ bool canAcceptUsbConsoleCommand() {
void serviceUsbTerminalPort() {
#if MESH_ESP32_TINYUSB_NONBLOCKING
serviceEsp32TinyUsbPorts();
#elif defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
#elif defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
const uint32_t reset_generation =
primaryUsbSessionGeneration();
if (reset_generation != primary_usb_terminal_seen_reset_generation) {
buffered_primary_usb_terminal_port.discardPending();
if (!buffered_primary_usb_terminal_port.tryDiscardPending()) return;
primary_usb_terminal_seen_reset_generation = reset_generation;
}
if (primary_usb_terminal_discard_pending.exchange(false, std::memory_order_acq_rel)
&& !buffered_primary_usb_terminal_port.tryDiscardPending()) {
primary_usb_terminal_discard_pending.store(true, std::memory_order_release);
return;
}
buffered_primary_usb_terminal_port.service();
#elif MESH_ESP32_HWCDC_SESSION_GUARD
serviceEsp32HwcdcTxKick();
@@ -1152,8 +1288,9 @@ void discardUsbTerminalOutput() {
esp32_tinyusb_terminal_dropped_bytes.load(std::memory_order_relaxed);
esp32_tinyusb_terminal_discard_pending.store(false, std::memory_order_release);
esp32_tinyusb_queue_busy.clear(std::memory_order_release);
#elif defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
buffered_primary_usb_terminal_port.discardPending();
#elif defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
primary_usb_terminal_discard_pending.store(true, std::memory_order_release);
serviceUsbTerminalPort();
#endif
}
@@ -1163,8 +1300,9 @@ bool hasPendingUsbTerminalOutput() {
const bool pending = esp32_tinyusb_text_queue.queuedByteCount() != 0;
esp32_tinyusb_queue_busy.clear(std::memory_order_release);
return pending;
#elif defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
return buffered_primary_usb_terminal_port.queuedByteCount() != 0;
#elif defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
return primary_usb_terminal_discard_pending.load(std::memory_order_acquire)
|| buffered_primary_usb_terminal_port.queuedByteCount() != 0;
#else
return false;
#endif
@@ -1185,7 +1323,7 @@ bool takeUsbTerminalSessionReset() {
if (reset_generation == esp32_tinyusb_taken_reset_generation) return false;
esp32_tinyusb_taken_reset_generation = reset_generation;
return true;
#elif defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
#elif defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
const uint32_t reset_generation =
primaryUsbSessionGeneration();
if (reset_generation == primary_usb_terminal_taken_reset_generation) {
@@ -1206,7 +1344,7 @@ bool takeUsbTerminalSessionReset() {
#endif
}
#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
#if defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
static void completePrimaryUsbSessionReset(void*) {
// This runs under the primary SingleAttempt stream's producer gate. It
// removes a write that raced the owner-task close callback before allowing
@@ -1227,12 +1365,22 @@ bool tryCompleteUsbTerminalSessionReset() {
serviceEsp32TinyUsbPorts();
return esp32_tinyusb_clean_generation.load(std::memory_order_acquire)
== esp32_tinyusb_reset_generation.load(std::memory_order_acquire);
#elif defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
#elif defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
serviceUsbTerminalPort();
const uint32_t generation = primaryUsbSessionGeneration();
if (primary_usb_terminal_discard_pending.load(std::memory_order_acquire)
|| primary_usb_terminal_seen_reset_generation != generation) return false;
// Ordinary roles call this every loop, not only on a reset. Never purge
// an already-open endpoint: that would drop every pending reply prefix.
if (primary_usb_allowed_generation.load(std::memory_order_acquire) == generation)
return true;
const uint32_t settle_until =
primary_usb_reset_settle_until.load(std::memory_order_acquire);
if ((int32_t)(millis() - settle_until) < 0) return false;
return nonblocking_primary_usb_companion_port.tryRunExclusive(
completePrimaryUsbSessionReset);
if (!nonblocking_primary_usb_companion_port.tryRunExclusive(
completePrimaryUsbSessionReset)) return false;
return primary_usb_allowed_generation.load(std::memory_order_acquire)
== primaryUsbSessionGeneration();
#elif MESH_ESP32_HWCDC_SESSION_GUARD
if (!esp32_hwcdc_cleanup_pending
&& esp32_hwcdc_allowed_generation.load(std::memory_order_acquire)
@@ -1277,9 +1425,138 @@ const char* usbLoggingPortDescription() {
#endif
}
UsbLoggingObservation observeUsbLoggingTransport() {
UsbLoggingObservation result;
result.tx_progress = usb_logging_tx_progress.load(std::memory_order_acquire);
#if MESH_ESP32_TINYUSB_NONBLOCKING
result.supported = true;
if (xPortInIsrContext()) return result;
result.host_connected = tud_mounted();
result.reader_connected = tud_cdc_n_connected(0);
result.pending = hasPendingUsbTerminalOutput()
|| usb_logging_tx_waiting.load(std::memory_order_acquire);
#elif MESH_ESP32_HWCDC_SESSION_GUARD
result.supported = true;
if (xPortInIsrContext()) return result;
result.host_connected = Serial.isPlugged();
result.reader_connected = result.host_connected && bool(Serial);
const size_t capacity = esp32_hwcdc_tx_buffer_capacity.load(std::memory_order_acquire);
result.pending = usb_logging_tx_waiting.load(std::memory_order_acquire)
|| (capacity != 0 && Serial.availableForWrite() < int(capacity));
#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB)
result.supported = true;
const uint8_t instance = hasDedicatedUsbLoggingPort() ? 1 : 0;
result.host_connected = tud_mounted();
result.reader_connected = tud_cdc_n_connected(instance);
result.pending = usb_logging_tx_waiting.load(std::memory_order_acquire)
|| tud_cdc_n_write_available(instance) < CFG_TUD_CDC_TX_BUFSIZE;
#if defined(MESH_DUAL_CDC_LOGGING)
result.pending = result.pending || usb_task_dedicated_usb_logging_port.queuedRecordCount() != 0;
#else
result.pending = result.pending || hasPendingUsbTerminalOutput();
#endif
#endif
return result;
}
void probeUsbLoggingTransport() {
if (!isUsbLoggingEnabled()) return;
#if MESH_ESP32_TINYUSB_NONBLOCKING || MESH_ESP32_HWCDC_SESSION_GUARD
if (xPortInIsrContext()) return;
#endif
static const uint8_t record[] = "[USB watchdog] heartbeat\r\n";
#if MESH_ESP32_TINYUSB_NONBLOCKING || MESH_ESP32_HWCDC_SESSION_GUARD \
|| defined(MESH_DUAL_CDC_LOGGING) || MESH_NRF52_USB_CONSOLE_COOPERATIVE
Stream& port = usbLoggingPort();
if (port.availableForWrite() >= int(sizeof(record) - 1))
(void)port.write(record, sizeof(record) - 1);
#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB)
// Ordinary nRF52 roles may still use the framework Serial facade. Never
// call its retrying write path from the watchdog: use one native attempt.
if (tud_cdc_n_connected(0)
&& tud_cdc_n_write_available(0) >= sizeof(record) - 1) {
noteUsbLoggingTxAttempt();
(void)tud_cdc_n_write(0, record, sizeof(record) - 1);
(void)tud_cdc_n_write_flush(0);
}
#endif
}
UsbLoggingRecoveryResult recoverUsbLoggingTransport(uint8_t stage) {
if (!isUsbLoggingEnabled() || (stage != 1 && stage != 2))
return UsbLoggingRecoveryResult::Unsupported;
#if MESH_ESP32_TINYUSB_NONBLOCKING
if (xPortInIsrContext()) return UsbLoggingRecoveryResult::Deferred;
if (stage == 1) {
if (!resetUsbCompanionTransport()) return UsbLoggingRecoveryResult::Deferred;
probeUsbLoggingTransport();
} else {
if (!usb_logging_watchdog_detached) {
if (!tud_disconnect()) return UsbLoggingRecoveryResult::Deferred;
usb_logging_watchdog_attach_at = millis() + 50;
usb_logging_watchdog_detached = true;
}
}
return UsbLoggingRecoveryResult::Attempted;
#elif MESH_ESP32_HWCDC_SESSION_GUARD
if (xPortInIsrContext()) return UsbLoggingRecoveryResult::Deferred;
if (stage == 1) {
// First just re-kick the existing ISR. Purge/PHY reconnection is the next
// stage, and the bounded existing helper retains the driver/descriptors.
serviceEsp32HwcdcTxKick();
probeUsbLoggingTransport();
} else if (!resetUsbCompanionTransport()) {
return UsbLoggingRecoveryResult::Deferred;
}
return UsbLoggingRecoveryResult::Attempted;
#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB)
if (stage == 1) {
#if defined(MESH_DUAL_CDC_LOGGING)
// Only publish a request. FIFO/identity/queue mutation remains in CDC1's
// existing TinyUSB-owner SOF service, never in this application call.
dedicated_usb_logging_watchdog_reset_requested.store(true, std::memory_order_release);
dedicated_usb_logging_port_connected.store(false, std::memory_order_release);
if (tud_cdc_n_connected(1)) tud_sof_cb_enable(true);
#else
endPrimaryUsbHostSession(false);
(void)tud_cdc_n_write_clear(0);
probeUsbLoggingTransport();
#endif
} else if (!usb_logging_watchdog_detached) {
if (!tud_disconnect()) return UsbLoggingRecoveryResult::Deferred;
usb_logging_watchdog_attach_at = millis() + 50;
usb_logging_watchdog_detached = true;
}
return UsbLoggingRecoveryResult::Attempted;
#else
return UsbLoggingRecoveryResult::Unsupported;
#endif
}
bool isUsbLoggingTransportRecoveryPending() {
#if MESH_ESP32_TINYUSB_NONBLOCKING \
|| (defined(NRF52_PLATFORM) && defined(USE_TINYUSB))
return usb_logging_watchdog_detached;
#elif MESH_ESP32_HWCDC_SESSION_GUARD
return esp32_hwcdc_cleanup_pending;
#else
return false;
#endif
}
} // namespace mesh
#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB)
extern "C" void meshTinyUsbLoggingTxComplete(uint8_t instance) {
#if defined(MESH_DUAL_CDC_LOGGING)
if (instance == 1) mesh::noteUsbLoggingTxComplete();
#else
if (instance == 0) mesh::noteUsbLoggingTxComplete();
#endif
}
#endif
#if defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
// A protocol-only, weak-declaration-free translation unit supplies strong
// versions of TinyUSB's callbacks and forwards here. Preserve Adafruit's CDC0
// 1200-baud touch behavior exactly, capture every CDC0 host-session close and
@@ -1365,4 +1642,5 @@ extern "C" void meshTinyUsbStartOfFrame(uint32_t frame_count) {
}
#endif
#endif
#endif
+23 -3
View File
@@ -1,8 +1,10 @@
#pragma once
#include "UsbLoggingStatus.h"
// Canonical USB-capable images compile at least one of these two diagnostics.
// One runtime gate covers every diagnostic category that writes to the USB
// Serial stream, so a separate logging firmware is unnecessary.
// A master USB output gate and a separate debug-verbosity gate keep packet
// capture available without verbose diagnostics or a separate logging image.
#if !defined(MESH_USB_LOGGING_DISABLED) && defined(ARDUINO) && \
((defined(MESH_DEBUG) && MESH_DEBUG) || \
(defined(MESH_PACKET_LOGGING) && MESH_PACKET_LOGGING))
@@ -41,6 +43,14 @@
#define MESH_ESP32_USB_CONSOLE_COOPERATIVE 0
#endif
#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB)
#define MESH_NRF52_USB_CONSOLE_COOPERATIVE 1
#else
#define MESH_NRF52_USB_CONSOLE_COOPERATIVE 0
#endif
#define MESH_USB_CONSOLE_COOPERATIVE \
(MESH_ESP32_USB_CONSOLE_COOPERATIVE || MESH_NRF52_USB_CONSOLE_COOPERATIVE)
namespace mesh {
#ifndef MESH_ESP32_USB_TX_BUFFER_SIZE
@@ -65,6 +75,13 @@ inline size_t nextUsbLoggingIdentityOffset(size_t current_offset,
// protect framed traffic and restores its saved choice after preferences load.
bool isUsbLoggingEnabled();
void setUsbLoggingEnabled(bool enabled);
// Verbose diagnostics are opt-in and persisted by the role preferences.
// The requested setting survives switching the master USB output gate off.
bool isUsbDebugEnabled();
void setUsbDebugEnabled(bool enabled);
inline bool isUsbDebugLoggingEnabled() {
return isUsbLoggingEnabled() && isUsbDebugEnabled();
}
// Kept as a platform-neutral persistence hook. Single-TTY builds need no
// descriptor mirror; nRF52 selects its optional second interface after loading
@@ -91,6 +108,9 @@ void beginUsbLoggingPort();
// such as /dev/ttyACM1 or COM7.
void serviceUsbLoggingPort();
Stream& usbLoggingPort();
// Debug-only output uses the same bounded transport when BOTH gates are on.
// Packet records, identity markers and functional replies must not use this.
Stream& usbDebugPort();
// Primary USB Companion data stream. Native TinyUSB on nRF52 and ESP32 uses
// one FIFO attempt per write, never the framework's wait-for-space loop.
// Callers retain and retry unwritten suffixes. Other platforms retain Serial
@@ -99,7 +119,7 @@ Stream& usbCompanionPort();
// A primary nRF52 client may send commands with DTR low (stock MeshCLI does).
// Accept actual input in the current USB session as proof of a reader, while
// retaining the same close/reset generation gate used by the transport.
#if defined(NRF52_PLATFORM) && defined(ENABLE_USB_INTERFACE)
#if defined(NRF52_PLATFORM) && (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
bool isUsbCompanionClientConnected();
#endif
// Serial mOTA requests are binary records of at most 11 bytes. On TinyUSB this
+53
View File
@@ -0,0 +1,53 @@
#include "UsbLoggingClientActivity.h"
#if defined(ARDUINO)
#include <Arduino.h>
#include "UsbLogging.h"
#if MESH_USB_CONSOLE_COOPERATIVE
#include <atomic>
namespace mesh {
namespace {
std::atomic<uint32_t> session_generation{1};
std::atomic<uint32_t> stats_generation{0};
std::atomic<uint32_t> stats_deadline{0};
}
void noteUsbLoggingStatsCommand(const char* command) {
if (!isUsbLoggingStatsCommand(command)) return;
const uint32_t generation = session_generation.load(std::memory_order_acquire);
uint32_t deadline = millis() + USB_LOGGING_CLIENT_LEASE_MS;
if (!deadline) deadline = UINT32_MAX; // Reserve zero; at most 1 ms early.
stats_deadline.store(deadline, std::memory_order_relaxed);
stats_generation.store(generation, std::memory_order_release);
}
bool isUsbLoggingClientActive() {
const uint32_t generation = stats_generation.load(std::memory_order_acquire);
if (!generation || generation != session_generation.load(std::memory_order_acquire))
return false;
uint32_t deadline = stats_deadline.load(std::memory_order_relaxed);
if (!deadline) return false;
if (int32_t(deadline - millis()) > 0) return true;
// Latch expiry, so millis wrapping weeks later cannot revive an old poll.
// Compare the timestamp, not just the session: an owner-task renewal that
// raced this expiry must retain its new deadline.
stats_deadline.compare_exchange_strong(deadline, 0);
return false;
}
void clearUsbLoggingClientActivity() {
// A callback racing a poll invalidates that poll's old generation without
// blocking either the USB owner task or the application loop.
session_generation.fetch_add(1, std::memory_order_acq_rel);
}
} // namespace mesh
#else
namespace mesh {
void noteUsbLoggingStatsCommand(const char*) {}
bool isUsbLoggingClientActive() { return false; }
void clearUsbLoggingClientActivity() {}
} // namespace mesh
#endif // MESH_USB_CONSOLE_COOPERATIVE
#endif // ARDUINO
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
namespace mesh {
// meshcoretomqtt polls these commands every five minutes. A short-lived lease
// proves application input, unlike a cable, DTR, or a kernel USB ACK. It does
// not identify a particular application or prove MQTT/disk health.
constexpr uint32_t USB_LOGGING_CLIENT_LEASE_MS = 15UL * 60UL * 1000UL;
inline bool isUsbLoggingStatsCommand(const char* command) {
if (!command) return false;
while (*command == ' ' || *command == '\t') ++command;
const char* const keys[] = {"stats-core", "stats-radio", "stats-packets"};
for (const char* key : keys) {
const size_t length = strlen(key);
if (strncmp(command, key, length)) continue;
const char* end = command + length;
while (*end == ' ' || *end == '\t') ++end;
if (!*end) return true;
}
return false;
}
// Bounded receive-only parser for the dedicated logging CDC. It recognizes
// stats polls but does not expose a second configuration or binary CLI. A
// truncated/overlong record must never count as a valid poll.
class UsbLoggingStatsLineParser {
public:
void reset() { length = 0; discard = false; }
bool consume(char c) {
if (c == '\r' || c == '\n') {
line[length] = 0;
const bool matched = !discard && isUsbLoggingStatsCommand(line);
reset();
return matched;
}
if (discard) return false;
if ((static_cast<uint8_t>(c) < 32 && c != '\t')
|| static_cast<uint8_t>(c) >= 127) {
discard = true;
length = 0;
return false;
}
if (length == sizeof(line) - 1) {
discard = true;
length = 0;
} else {
line[length++] = c;
}
return false;
}
private:
char line[32] = {};
size_t length = 0;
bool discard = false;
};
// Call only after receiving a complete command on the actual USB logging
// endpoint. BLE, TCP, LoRa, and the primary CDC of a dual-CDC device cannot
// establish a logging-reader lease. These calls never persist preferences.
void noteUsbLoggingStatsCommand(const char* command);
bool isUsbLoggingClientActive();
void clearUsbLoggingClientActivity();
} // namespace mesh
+10 -1
View File
@@ -4,8 +4,17 @@
// strong callbacks which override Adafruit's weak defaults for nRF52 USB
// Companion builds. CDC0 needs exact close events even on single-CDC targets;
// dual-CDC builds additionally forward CDC1 reconnect events.
#if defined(ARDUINO) && defined(NRF52_PLATFORM) && defined(USE_TINYUSB)
#include <stdint.h>
extern "C" void meshTinyUsbLoggingTxComplete(uint8_t instance);
// No weak callback declaration is imported in this translation unit.
extern "C" void tud_cdc_tx_complete_cb(uint8_t instance) {
meshTinyUsbLoggingTxComplete(instance);
}
#endif
#if defined(ARDUINO) && defined(NRF52_PLATFORM) \
&& defined(ENABLE_USB_INTERFACE)
&& (defined(ENABLE_USB_INTERFACE) || defined(USE_TINYUSB))
#include <class/cdc/cdc.h>
extern "C" void meshTinyUsbCdcLineStateChanged(uint8_t instance, bool dtr,
+55
View File
@@ -0,0 +1,55 @@
#pragma once
#include <stdint.h>
#include "UsbLoggingWatchdogEvent.h"
namespace mesh {
// Public, non-secret status shared by the CLI and management reports.
struct UsbLoggingStatus {
bool supported = false;
bool logging_enabled = false;
bool watchdog_enabled = false;
bool watchdog_auto = false;
bool host_connected = false;
bool reader_connected = false;
bool logger_active = false;
bool stalled = false;
bool recovering = false;
bool recovery_deferred = false;
bool persistence_ready = false;
uint8_t stage = 0;
uint8_t backoff_step = 0;
uint32_t retry_seconds = 3600;
uint32_t inactive_seconds = 0;
uint32_t auto_connected_seconds = 0;
uint32_t recovery_count = 0;
uint32_t reboot_count = 0;
UsbLoggingWatchdogEvent last_event;
};
struct UsbLoggingObservation {
bool supported = false;
bool host_connected = false;
bool reader_connected = false;
bool pending = false;
// Changes ONLY for a downstream USB TX-completion event, never enqueue,
// queue discard, driver initialization, or our own recovery request.
uint32_t tx_progress = 0;
};
enum class UsbLoggingRecoveryResult : uint8_t {
Deferred, Attempted, Unsupported
};
UsbLoggingStatus usbLoggingStatus();
UsbLoggingObservation observeUsbLoggingTransport();
// stage 1: bounded local cleanup/probe; stage 2: descriptor-preserving USB
// re-enumeration. Neither function may reboot the board or wait for a host.
UsbLoggingRecoveryResult recoverUsbLoggingTransport(uint8_t stage);
// Optional watchdog heartbeat. Bounded and plaintext logging-only; accepting
// the record into a queue is not proof of host progress.
void probeUsbLoggingTransport();
bool isUsbLoggingTransportRecoveryPending();
} // namespace mesh
+588
View File
@@ -0,0 +1,588 @@
#if defined(ARDUINO)
#include "UsbLoggingWatchdog.h"
#include "UsbLoggingWatchdogPolicy.h"
#include "UsbLogging.h"
#include "UsbLoggingClientActivity.h"
#include <stdio.h>
#include <string.h>
#if MESH_USB_CONSOLE_COOPERATIVE
#include "FilePresence.h"
#include "PersistentStoreFormat.h"
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
#include "AtomicFileWriter.h"
#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
#include "ContactFileTransaction.h"
#endif
#if defined(ENABLE_OTA)
#include "ota/OtaContext.h"
#endif
#include <Arduino.h>
#include <atomic>
namespace mesh {
namespace {
using Policy = UsbLoggingWatchdogPolicy;
using Event = UsbLoggingWatchdogEvent;
constexpr size_t LegacyImageSize = 20;
constexpr size_t ImageSize = 33;
constexpr size_t EventOffset = 16;
constexpr size_t CrcOffset = EventOffset + USB_WATCHDOG_EVENT_SIZE;
constexpr uint32_t ProbeMs = 60000;
constexpr uint32_t DeferredRetryMs = 5000;
const char* const Path = "/usb_wdg";
const char* const Backup = "/usb_wdg.bak";
struct Config {
uint8_t mode = 2; // Off=0, On=1, Auto=2; new nodes qualify before arming.
uint8_t step = 0;
uint32_t recoveries = 0;
uint32_t reboots = 0;
Event last_event;
};
enum class ReadResult : uint8_t { Missing, Valid, Invalid, Unreadable };
FILESYSTEM* settings_fs = nullptr;
Config config;
Policy policy;
// Separate USB health from application stats health. Actions from this tracker
// are ignored; it keeps physical TX stalls observable after a lease expires.
Policy physical_policy;
UsbLoggingAutoArmPolicy auto_arm;
bool persistence_ready = false;
bool settings_durable = false;
bool backend_unsupported = false;
bool backend_deferred = false;
bool backend_retry_started = false;
bool reader_seen = false;
uint32_t backend_retry_at = 0;
uint32_t probe_at = 0;
uint32_t (*event_epoch_seconds)() = nullptr;
uint32_t uptime_last_tick = 0;
uint64_t uptime_ms = 0;
bool event_pending = false;
// Only the application loop/CLI writes policy and configuration. Management
// readers get a bounded, non-blocking snapshot without filesystem/USB calls.
std::atomic<uint32_t> status_flags{0}, status_stage_step{0};
std::atomic<uint32_t> status_retry{3600}, status_inactive{0};
std::atomic<uint32_t> status_recoveries{0}, status_reboots{0};
std::atomic<uint32_t> status_auto_seconds{0};
std::atomic<uint32_t> status_event_guard{0}, status_event_code{0};
std::atomic<uint32_t> status_event_epoch{0}, status_event_uptime{0}, status_event_sequence{0};
uint32_t incrementSaturated(uint32_t value) {
return value == UINT32_MAX ? value : value + 1;
}
void tickUptime(uint32_t now) {
const uint32_t delta = now - uptime_last_tick;
if (delta > 0x80000000UL && uint32_t(uptime_last_tick - now) <= 5000) return;
uptime_ms += delta;
uptime_last_tick = now;
}
uint8_t physicalReasons(const UsbLoggingObservation& observation) {
return (!observation.host_connected ? Event::HOST_ABSENT : 0)
| (!observation.reader_connected ? Event::READER_ABSENT : 0)
| (physical_policy.stalled() ? Event::TX_STALLED : 0);
}
Event newEvent(uint8_t action, uint8_t reasons) {
Event event;
event.action = action;
event.reasons = reasons;
event.epoch = event_epoch_seconds ? event_epoch_seconds() : 0;
const uint64_t seconds = uptime_ms / 1000;
event.uptime_seconds = seconds > UINT32_MAX ? UINT32_MAX : uint32_t(seconds);
event.sequence = incrementSaturated(config.last_event.sequence);
return event;
}
void publish(const UsbLoggingObservation& observation) {
const bool logger_active = isUsbLoggingEnabled() && observation.host_connected
&& observation.reader_connected && isUsbLoggingClientActive() && !physical_policy.stalled();
const uint32_t flags = (observation.supported && !backend_unsupported ? 1U : 0U)
| (isUsbLoggingEnabled() ? 2U : 0U) | (config.mode == 1 ? 4U : 0U)
| (observation.host_connected ? 8U : 0U)
| (observation.reader_connected ? 16U : 0U)
| (physical_policy.stalled() ? 32U : 0U) | (policy.stage() ? 64U : 0U)
| (policy.deferred() || backend_deferred ? 128U : 0U)
| (persistence_ready ? 256U : 0U) | (config.mode == 2 ? 512U : 0U)
| (reader_seen ? 1024U : 0U) | (logger_active ? 2048U : 0U);
status_stage_step.store(uint32_t(policy.stage()) | (uint32_t(config.step) << 8));
status_retry.store(Policy::intervalMs(config.step) / 1000);
status_inactive.store(policy.inactiveSeconds());
status_recoveries.store(config.recoveries);
status_reboots.store(config.reboots);
status_auto_seconds.store(auto_arm.connectedSeconds());
// A bounded sequence guard prevents mixing fields from different events.
// All words are atomic, and seq_cst keeps the guard around their publication.
status_event_guard.fetch_add(1);
status_event_code.store(config.last_event.reasons | (uint32_t(config.last_event.action) << 4)
| (config.last_event.persisted ? 128U : 0U));
status_event_epoch.store(config.last_event.epoch);
status_event_uptime.store(config.last_event.uptime_seconds);
status_event_sequence.store(config.last_event.sequence);
status_event_guard.fetch_add(1);
status_flags.store(flags, std::memory_order_release);
}
ReadResult readImage(const char* path, uint8_t* image, size_t* read_size = nullptr) {
bool present = false;
if (!filePresence(settings_fs, path, present)) return ReadResult::Unreadable;
if (!present) return ReadResult::Missing;
#if defined(NRF52_PLATFORM)
File file(*settings_fs);
if (!file.open(path, FILE_O_READ)) return ReadResult::Unreadable;
#elif defined(STM32_PLATFORM)
File file = settings_fs->open(path, FILE_O_READ);
#else
File file = settings_fs->open(path, "r");
#endif
if (!file) return ReadResult::Unreadable;
const size_t size = file.size();
const bool legacy = size == LegacyImageSize;
const bool right_size = legacy || size == ImageSize;
const bool complete = right_size && static_cast<size_t>(file.read(image, size)) == size;
file.close();
if (!right_size) return ReadResult::Invalid;
if (!complete) return ReadResult::Unreadable;
const size_t crc_offset = size - sizeof(uint32_t);
const bool valid = image[0] == 'U' && image[1] == 'W'
&& image[2] == (legacy ? 1 : 2) && image[3] == 0 && image[4] <= 2
&& image[5] <= Policy::MAX_STEP && image[6] == 0 && image[7] == 0
&& storage::readLE32(image + crc_offset)
== (storage::updateCRC32(0xffffffffU, image, crc_offset) ^ 0xffffffffU);
Event event;
if (!valid || (!legacy && (!decodeUsbWatchdogEvent(image + EventOffset, event)
|| (event.sequence && !event.persisted)))) return ReadResult::Invalid;
if (read_size) *read_size = size;
return ReadResult::Valid;
}
bool saveConfig(const Config& candidate, bool explicit_write = false) {
// Automatic actions stop after any unverifiable commit. An explicit CLI
// setter may retry/repair only this independent statefile on durable media.
if (!settings_fs || !settings_durable || (!persistence_ready && !explicit_write)) return false;
uint8_t image[ImageSize] = {'U', 'W', 2, 0};
image[4] = candidate.mode;
image[5] = candidate.step;
storage::writeLE32(image + 8, candidate.recoveries);
storage::writeLE32(image + 12, candidate.reboots);
Event durable_event = candidate.last_event;
durable_event.persisted = durable_event.sequence != 0;
encodeUsbWatchdogEvent(image + EventOffset, durable_event);
storage::writeLE32(image + CrcOffset,
storage::updateCRC32(0xffffffffU, image, CrcOffset) ^ 0xffffffffU);
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
AtomicFileWriter writer(settings_fs, Path);
#elif defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
ContactFileTransaction writer(settings_fs, Path, filePresence<FILESYSTEM>);
#else
persistence_ready = false;
return false;
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) \
|| defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)
bool ok = writer && writer.write(image, sizeof(image)) == sizeof(image)
&& writer.commit();
uint8_t verify[ImageSize];
size_t verify_size = 0;
ok = ok && readImage(Path, verify, &verify_size) == ReadResult::Valid && verify_size == ImageSize
&& !memcmp(image, verify, sizeof(image));
persistence_ready = ok; // Never reset on an unverified tier.
return ok;
#endif
}
bool safeNow(bool (*safe)(void*), void* context) {
return safe && !isUsbLoggingWatchdogUpdateActive() && safe(context);
}
bool keyMatches(const char* command, const char* key) {
const size_t length = strlen(key);
return strncmp(command, key, length) == 0
&& (command[length] == 0 || command[length] == ' ' || command[length] == '\t');
}
} // namespace
UsbLoggingStatus usbLoggingStatus() {
UsbLoggingStatus status;
const uint32_t flags = status_flags.load(std::memory_order_acquire);
const uint32_t stage_step = status_stage_step.load();
status.supported = flags & 1U;
status.logging_enabled = flags & 2U;
status.watchdog_enabled = flags & 4U;
status.host_connected = flags & 8U;
status.reader_connected = flags & 16U;
status.stalled = flags & 32U;
status.recovering = flags & 64U;
status.recovery_deferred = flags & 128U;
status.persistence_ready = flags & 256U;
status.watchdog_auto = flags & 512U;
status.logger_active = flags & 2048U;
status.stage = stage_step & 0xff;
status.backoff_step = (stage_step >> 8) & 0xff;
status.retry_seconds = status_retry.load();
status.inactive_seconds = status_inactive.load();
status.recovery_count = status_recoveries.load();
status.reboot_count = status_reboots.load();
status.auto_connected_seconds = status_auto_seconds.load();
for (uint8_t attempt = 0; attempt < 3; ++attempt) {
const uint32_t before = status_event_guard.load();
if (before & 1U) continue;
Event event;
const uint32_t code = status_event_code.load();
event.reasons = code & 15U;
event.action = (code >> 4) & 7U;
event.persisted = code & 128U;
event.epoch = status_event_epoch.load();
event.uptime_seconds = status_event_uptime.load();
event.sequence = status_event_sequence.load();
if (before == status_event_guard.load()) {
status.last_event = event;
break;
}
}
return status;
}
bool isUsbLoggingWatchdogArmed() {
if (isUsbLoggingTransportRecoveryPending()) return true;
const auto status = usbLoggingStatus();
const bool seen = status_flags.load(std::memory_order_acquire) & 1024U;
return status.supported && status.logging_enabled
&& (status.watchdog_enabled || (status.watchdog_auto && seen && status.stage < 2))
&& status.persistence_ready;
}
void loadUsbLoggingWatchdog(FILESYSTEM* fs, bool durable, uint32_t (*epoch_seconds)()) {
settings_fs = fs;
settings_durable = fs && durable;
config = Config();
persistence_ready = false;
backend_unsupported = backend_deferred = backend_retry_started = false;
reader_seen = false;
event_epoch_seconds = epoch_seconds;
uptime_last_tick = millis();
uptime_ms = uptime_last_tick;
event_pending = false;
clearUsbLoggingClientActivity();
probe_at = millis();
uint8_t image[ImageSize];
size_t image_size = 0;
if (fs && durable) {
ReadResult result = readImage(Path, image, &image_size);
if (result == ReadResult::Missing) {
result = readImage(Backup, image, &image_size);
if (result == ReadResult::Valid) {
// Repair only a conclusively absent primary. Never remove or replace
// unreadable/corrupt images automatically, even with a good backup.
const bool renamed = fs->rename(Backup, Path);
uint8_t verify[ImageSize];
size_t verify_size = 0;
if (!renamed || readImage(Path, verify, &verify_size) != ReadResult::Valid
|| image_size != verify_size || memcmp(image, verify, image_size)) {
result = ReadResult::Unreadable;
}
}
}
if (result == ReadResult::Valid) {
config.mode = image[4];
config.step = image[5];
config.recoveries = storage::readLE32(image + 8);
config.reboots = storage::readLE32(image + 12);
if (image_size == ImageSize) decodeUsbWatchdogEvent(image + EventOffset, config.last_event);
persistence_ready = true;
} else if (result == ReadResult::Missing) {
persistence_ready = true; // Fresh durable store qualifies in Auto.
}
}
policy.begin(millis(), config.step);
physical_policy.begin(millis(), config.step);
auto_arm.begin(millis());
publish(observeUsbLoggingTransport());
}
bool isUsbLoggingWatchdogUpdateActive() {
#if defined(ENABLE_OTA)
const auto* ota = mesh::ota::ota_context_if_active();
if (ota) {
const auto state = ota->manager.fetchState();
if (ota->apply_pending || ota->bootloader_apply_pending || ota->folder_active
|| ota->folder_dest || ota->folderCaptureWaiting()
|| ota->manager.pendingServeJobs() || ota->manager.pendingManifestJobs()
|| ota->serve_expected || (state != mesh::ota::OtaManager::IDLE
&& state != mesh::ota::OtaManager::FAILED)) return true;
}
#endif
return false;
}
bool handleUsbLoggingWatchdogCommand(const char* command, char* reply,
size_t capacity) {
if (!command || !reply || !capacity) return false;
if (keyMatches(command, "get usb.watchdog.last")) {
if (strcmp(command, "get usb.watchdog.last")) {
snprintf(reply, capacity, "Error: usage get usb.watchdog.last");
return true;
}
const auto event = usbLoggingStatus().last_event;
snprintf(reply, capacity, "> %s reasons=0x%02X epoch=%lu uptime=%lus seq=%lu persisted=%u",
usbWatchdogActionName(event.action), unsigned(event.reasons), (unsigned long)event.epoch,
(unsigned long)event.uptime_seconds, (unsigned long)event.sequence, event.persisted);
return true;
}
if (keyMatches(command, "get usb.watchdog")) {
if (strcmp(command, "get usb.watchdog")) {
snprintf(reply, capacity, "Error: usage get usb.watchdog");
return true;
}
publish(observeUsbLoggingTransport());
const auto status = usbLoggingStatus();
snprintf(reply, capacity,
"> %s usb=%u log=%u host=%u reader=%u logger=%u stall=%u stage=%u step=%u "
"retry=%lus idle=%lus auto=%lus defer=%u fs=%u rec=%lu boot=%lu",
status.watchdog_auto ? "auto" : (status.watchdog_enabled ? "on" : "off"),
status.supported,
status.logging_enabled, status.host_connected, status.reader_connected, status.logger_active,
status.stalled, status.stage, status.backoff_step,
(unsigned long)status.retry_seconds, (unsigned long)status.inactive_seconds,
(unsigned long)status.auto_connected_seconds,
status.recovery_deferred, status.persistence_ready,
(unsigned long)status.recovery_count, (unsigned long)status.reboot_count);
return true;
}
if (!keyMatches(command, "set usb.watchdog")) return false;
const char* value = command + strlen("set usb.watchdog");
while (*value == ' ' || *value == '\t') ++value;
if (strcmp(value, "on") && strcmp(value, "off") && strcmp(value, "auto")) {
snprintf(reply, capacity, "Error: usage set usb.watchdog off|on|auto");
return true;
}
const uint8_t mode = !strcmp(value, "on") ? 1 : (!strcmp(value, "auto") ? 2 : 0);
const auto observation = observeUsbLoggingTransport();
if (mode == 1 && !observation.supported) {
snprintf(reply, capacity, "Error: USB watchdog unsupported on this transport");
return true;
}
Config candidate = config;
candidate.mode = mode;
if (!saveConfig(candidate, true)) {
snprintf(reply, capacity, "Error: USB watchdog state not durable; unchanged");
} else {
config = candidate;
if (config.last_event.sequence) config.last_event.persisted = true;
event_pending = false;
backend_unsupported = backend_deferred = backend_retry_started = false;
reader_seen = false;
policy.begin(millis(), config.step);
physical_policy.begin(millis(), config.step);
auto_arm.begin(millis());
probe_at = millis();
snprintf(reply, capacity, "OK - USB watchdog %s (saved)", value);
}
publish(observation);
return true;
}
bool serviceUsbLoggingWatchdog(bool (*safe)(void*), void* context) {
const uint32_t now = millis();
tickUptime(now);
auto observation = observeUsbLoggingTransport();
physical_policy.update(now, isUsbLoggingEnabled(), observation);
const bool observing = config.mode != 0 && persistence_ready && isUsbLoggingEnabled()
&& !backend_unsupported;
if (!observing) reader_seen = false;
if (!isUsbLoggingEnabled() || !observation.host_connected || !observation.reader_connected) {
clearUsbLoggingClientActivity();
}
const bool lease_eligible = isUsbLoggingEnabled() && observation.host_connected
&& observation.reader_connected && isUsbLoggingClientActive();
auto policy_observation = observation;
// USB ACK/DTR proves transport availability, not application activity.
// Auto repairs a previously confirmed logger even when its process stops
// polling but the kernel continues acknowledging bulk USB data.
if (config.mode == 2 || reader_seen) policy_observation.reader_connected = lease_eligible;
const bool active = observing && (config.mode == 1 || reader_seen);
// Once confirmed, missing stats may request USB-only repair in On too.
// Never reboot an otherwise healthy USB transport for an app-only stats gap.
const bool physical_bad = !observation.host_connected || !observation.reader_connected
|| physical_policy.stalled();
const uint32_t physical_reboot_seconds = Policy::intervalMs(config.step) / 1000;
const bool reboot_allowed = config.mode == 1 && physical_bad
&& physical_policy.inactiveSeconds() >= physical_reboot_seconds;
const auto action = policy.update(now, active, policy_observation, true, reboot_allowed);
const bool logger_active = lease_eligible && !physical_policy.stalled();
if (observing && observation.supported && logger_active) reader_seen = true;
if (policy_observation.host_connected && policy_observation.reader_connected && !policy.stalled()) {
backend_deferred = false;
}
const bool auto_due = auto_arm.update(now,
observing && observation.supported && logger_active, config.mode == 2);
// The previous reset was already vetoed. Finishing its journal is not a
// recovery action and must not depend on logging remaining enabled.
if (event_pending && persistence_ready) {
if (safeNow(safe, context)) {
event_pending = false; // Failed verification disables automatic retries.
if (saveConfig(config)) config.last_event.persisted = true;
}
publish(observation);
return false;
}
if (!observing || !observation.supported) {
backend_deferred = backend_retry_started = false;
probe_at = now;
publish(observation);
return false;
}
if (uint32_t(now - probe_at) >= ProbeMs) {
probe_at = now; // Throttle unsafe/busy probes too, never a hot retry loop.
if ((config.mode == 1 || logger_active)
&& safeNow(safe, context)) probeUsbLoggingTransport();
}
if (config.mode == 2) {
if (auto_due) {
backend_deferred = !safeNow(safe, context);
if (!backend_deferred) {
Config candidate = config;
candidate.mode = 1;
candidate.step = 0; // Fourteen healthy days subsume the ten-minute reset.
if (saveConfig(candidate)) {
config = candidate;
if (config.last_event.sequence) config.last_event.persisted = true;
policy.begin(now, config.step);
physical_policy.backoffCommitted(config.step);
auto_arm.begin(now);
}
}
}
if (!reader_seen || auto_due) {
publish(observation);
return false;
}
}
if (action == Policy::Action::None) {
publish(observation);
return false;
}
if (!safeNow(safe, context)) {
policy.update(now, active, policy_observation, false, reboot_allowed);
publish(observation);
return false;
}
if (action == Policy::Action::ResetBackoff && config.mode == 1) {
Config candidate = config;
candidate.step = 0;
if (saveConfig(candidate)) {
config = candidate;
if (config.last_event.sequence) config.last_event.persisted = true;
policy.backoffCommitted(0);
}
} else if (action == Policy::Action::Reboot && config.mode == 1) {
Config candidate = config;
candidate.step = Policy::nextStep(config.step);
candidate.reboots = incrementSaturated(config.reboots);
candidate.last_event = newEvent(Event::REBOOT_REQUESTED, physicalReasons(observation));
if (saveConfig(candidate)) {
config = candidate;
config.last_event.persisted = true;
// Recheck real transport progress using the OLD deadline before raising
// the policy tier. A host may recover while its statefile is committing.
const auto fresh = observeUsbLoggingTransport();
const uint32_t fresh_now = millis();
physical_policy.update(fresh_now, isUsbLoggingEnabled(), fresh);
const bool fresh_physical_bad = !fresh.host_connected || !fresh.reader_connected
|| physical_policy.stalled();
auto fresh_policy = fresh;
if (config.mode == 2 || reader_seen) {
fresh_policy.reader_connected = fresh.host_connected && fresh.reader_connected
&& isUsbLoggingClientActive();
}
const bool still_due = policy.update(fresh_now, active, fresh_policy, true,
config.mode == 1 && fresh_physical_bad
&& physical_policy.inactiveSeconds() >= physical_reboot_seconds)
== Policy::Action::Reboot;
policy.backoffCommitted(config.step);
physical_policy.backoffCommitted(config.step);
// A transfer may have gained ownership while flash committed. Keeping
// the new tier but restarting the interval is safer than racing it.
if (still_due && safeNow(safe, context)) {
policy.actionAttempted(action);
publish(fresh);
return true;
}
policy.restartInterval();
backend_deferred = still_due;
// The verified reboot-intent journal is not evidence of a reset. Keep
// cancellation truthful in RAM even if its best-effort commit fails.
tickUptime(fresh_now);
config.last_event = newEvent(Event::REBOOT_CANCELLED, candidate.last_event.reasons);
event_pending = true;
if (safeNow(safe, context)) {
event_pending = false;
if (saveConfig(config)) config.last_event.persisted = true;
}
publish(fresh);
return false;
} else {
config.last_event = candidate.last_event;
}
} else if (action == Policy::Action::SoftRecovery || action == Policy::Action::Reenumerate) {
if (backend_retry_started && uint32_t(now - backend_retry_at) < DeferredRetryMs) {
publish(observation);
return false;
}
backend_retry_started = true;
backend_retry_at = now;
const uint8_t stage = action == Policy::Action::SoftRecovery ? 1 : 2;
const auto result = recoverUsbLoggingTransport(stage);
backend_deferred = result == UsbLoggingRecoveryResult::Deferred;
if (result == UsbLoggingRecoveryResult::Attempted) {
config.recoveries = incrementSaturated(config.recoveries);
policy.actionAttempted(action);
tickUptime(millis());
uint8_t reasons = physicalReasons(observation);
if (!reasons) reasons = Event::CLIENT_INACTIVE;
config.last_event = newEvent(stage == 1 ? Event::SOFT_RECOVERY : Event::REENUMERATE, reasons);
if (saveConfig(config)) config.last_event.persisted = true;
} else if (result == UsbLoggingRecoveryResult::Unsupported) {
backend_unsupported = true;
}
}
publish(observation);
return false;
}
} // namespace mesh
#else
// There is no native transport to observe or repair on UART-bridge/unsupported
// targets. Do not spend scarce flash on timers, transactions, or OTA vetoes
// that can never authorize recovery; retain truthful management/CLI status.
namespace mesh {
UsbLoggingStatus usbLoggingStatus() {
UsbLoggingStatus status;
status.logging_enabled = isUsbLoggingEnabled();
return status;
}
void loadUsbLoggingWatchdog(FILESYSTEM*, bool, uint32_t (*)()) {}
bool serviceUsbLoggingWatchdog(bool (*)(void*), void*) { return false; }
bool isUsbLoggingWatchdogArmed() { return false; }
bool isUsbLoggingWatchdogUpdateActive() { return false; }
bool handleUsbLoggingWatchdogCommand(const char* command, char* reply,
size_t capacity) {
if (!command || !reply || !capacity) return false;
static const char* const keys[] = {"get usb.watchdog.last", "get usb.watchdog", "set usb.watchdog"};
for (const char* key : keys) {
const size_t length = strlen(key);
if (strncmp(command, key, length)
|| (command[length] && command[length] != ' ' && command[length] != '\t')) continue;
snprintf(reply, capacity, "Error: unsupported");
return true;
}
return false;
}
} // namespace mesh
#endif // MESH_USB_CONSOLE_COOPERATIVE
#endif // ARDUINO
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "IdentityStore.h"
#include "UsbLoggingStatus.h"
#include <stddef.h>
namespace mesh {
// A separate versioned statefile keeps all deployed role preference offsets
// unchanged. New nodes default to Auto (14 continuous healthy USB days before
// arming). Volatile fallback filesystems must never authorize a reboot.
void loadUsbLoggingWatchdog(FILESYSTEM* fs, bool durable = true,
uint32_t (*epoch_seconds)() = nullptr);
bool handleUsbLoggingWatchdogCommand(const char* command, char* reply,
size_t capacity);
// Call from the application loop only. The callback protects protocol, radio
// and dirty-contact ownership and is rechecked after the durable tier commit.
// True authorizes the caller's board.reboot(); this helper never reboots.
bool serviceUsbLoggingWatchdog(bool (*safe)(void*), void* context = nullptr);
bool isUsbLoggingWatchdogArmed();
bool isUsbLoggingWatchdogUpdateActive();
} // namespace mesh
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <string.h>
namespace mesh {
// Timestamp is the node's advisory RTC, never proof of synchronized UTC.
// A reboot request is durable intent, not proof that a physical reset occurred.
struct UsbLoggingWatchdogEvent {
enum Reason : uint8_t {
HOST_ABSENT = 1, READER_ABSENT = 2, TX_STALLED = 4, CLIENT_INACTIVE = 8
};
enum Action : uint8_t {
NONE = 0, SOFT_RECOVERY = 1, REENUMERATE = 2,
REBOOT_REQUESTED = 3, REBOOT_CANCELLED = 4
};
uint8_t reasons = 0;
uint8_t action = NONE;
uint32_t epoch = 0;
uint32_t uptime_seconds = 0;
uint32_t sequence = 0;
bool persisted = false;
};
constexpr size_t USB_WATCHDOG_EVENT_SIZE = 13;
inline void encodeUsbWatchdogEvent(uint8_t* out, const UsbLoggingWatchdogEvent& event) {
memset(out, 0, USB_WATCHDOG_EVENT_SIZE);
if (!event.sequence) return;
out[0] = (event.reasons & 15u) | ((event.action & 7u) << 4)
| (event.persisted ? 128u : 0u);
const uint32_t words[] = {event.epoch, event.uptime_seconds, event.sequence};
for (size_t i = 0; i < 3; ++i) {
for (size_t j = 0; j < 4; ++j) out[1 + 4 * i + j] = words[i] >> (8 * j);
}
}
// Relays need only validate the wire shape, not construct or decode timestamps.
// A nonempty record has a nonzero sequence, reason mask and known action.
inline bool validUsbWatchdogEvent(const uint8_t* in) {
// memcpy keeps unaligned wire inputs legal; zero detection is independent
// of the host byte order and lets small MCUs use a single word load.
uint32_t sequence;
memcpy(&sequence, in + 9, sizeof(sequence));
if (sequence) {
const uint8_t action = (in[0] >> 4) & 7u;
return (in[0] & 15u) && action >= UsbLoggingWatchdogEvent::SOFT_RECOVERY
&& action <= UsbLoggingWatchdogEvent::REBOOT_CANCELLED;
}
uint32_t epoch, uptime;
memcpy(&epoch, in + 1, sizeof(epoch));
memcpy(&uptime, in + 5, sizeof(uptime));
return !(in[0] | epoch | uptime);
}
inline bool decodeUsbWatchdogEvent(const uint8_t* in, UsbLoggingWatchdogEvent& event) {
if (!validUsbWatchdogEvent(in)) return false;
UsbLoggingWatchdogEvent decoded;
decoded.reasons = in[0] & 15u;
decoded.action = (in[0] >> 4) & 7u;
decoded.persisted = in[0] & 128u;
uint32_t* words[] = {&decoded.epoch, &decoded.uptime_seconds, &decoded.sequence};
for (size_t i = 0; i < 3; ++i) {
for (size_t j = 0; j < 4; ++j) *words[i] |= uint32_t(in[1 + 4 * i + j]) << (8 * j);
}
event = decoded;
return true;
}
inline const char* usbWatchdogActionName(uint8_t action) {
switch (action) {
case UsbLoggingWatchdogEvent::SOFT_RECOVERY: return "soft-recovery";
case UsbLoggingWatchdogEvent::REENUMERATE: return "reenumerate";
case UsbLoggingWatchdogEvent::REBOOT_REQUESTED: return "reboot-requested";
case UsbLoggingWatchdogEvent::REBOOT_CANCELLED: return "reboot-cancelled";
default: return "none";
}
}
} // namespace mesh
+192
View File
@@ -0,0 +1,192 @@
#pragma once
#include "UsbLoggingStatus.h"
namespace mesh {
// Pure policy. Persistence, protocol ownership, USB operations and board
// reboot are deliberately handled by the caller, not from an ISR/USB task.
class UsbLoggingWatchdogPolicy {
public:
enum class Action : uint8_t { None, SoftRecovery, Reenumerate, Reboot, ResetBackoff };
static constexpr uint8_t MAX_STEP = 8;
static constexpr uint32_t BASE_MS = 3600000UL;
static constexpr uint32_t MAX_MS = 604800000UL;
static constexpr uint32_t EARLY_RECOVERY_MS = 300000UL;
static constexpr uint32_t RECOVERY_GRACE_MS = 60000UL;
static constexpr uint32_t HEALTHY_RESET_MS = 600000UL;
static constexpr uint32_t STALL_OBSERVE_MS = 30000UL;
static uint32_t intervalMs(uint8_t step) {
return step >= MAX_STEP ? MAX_MS : BASE_MS << step;
}
static uint8_t nextStep(uint8_t step) {
return step >= MAX_STEP ? MAX_STEP : uint8_t(step + 1);
}
void begin(uint32_t now, uint8_t step) {
_now = now;
_last_tick = now;
_step = step <= MAX_STEP ? step : MAX_STEP;
_clock_started = true;
clearTracking();
}
Action update(uint32_t now, bool active, const UsbLoggingObservation& observation,
bool recovery_safe = true, bool reboot_allowed = true) {
tick(now);
if (active && observation.supported && !_active) clearTracking();
_active = active && observation.supported;
const bool progress = _observed && observation.tx_progress != _progress;
_progress = observation.tx_progress;
if (!_observed || progress || !observation.pending) _pending_since = _now;
_observed = true;
_stalled = observation.host_connected && observation.reader_connected
&& observation.pending && _now - _pending_since >= STALL_OBSERVE_MS;
if (!active || !observation.supported) {
// Still observe USB stalls while Auto is qualifying or the watchdog is
// off, without accumulating a reboot deadline. Enabling always starts
// a new full interval via the activation edge above.
_fault = _healthy = _need_progress = _deferred = false;
_stage = 0;
return Action::None;
}
if (_stalled) _need_progress = true;
if (progress && observation.host_connected && observation.reader_connected) {
_need_progress = false;
}
const bool bad = !observation.host_connected || !observation.reader_connected
|| _stalled || _need_progress;
if (!bad) {
_fault = false;
_stage = 0;
_deferred = false;
if (!_healthy) { _healthy = true; _healthy_since = _now; }
const bool reset_due = _step != 0 && _now - _healthy_since >= HEALTHY_RESET_MS;
_deferred = reset_due && !recovery_safe;
return reset_due && recovery_safe ? Action::ResetBackoff : Action::None;
}
_healthy = false;
if (!_fault) {
_fault = true;
_fault_since = _stalled ? _pending_since : _now;
}
// A slow but genuinely draining endpoint gets a full new interval. A
// purge or accepted producer write cannot postpone this deadline.
if (progress && observation.host_connected && observation.reader_connected
&& _stage == 0) _fault_since = _now;
const bool due = _stage != 0 || _now - _fault_since >= EARLY_RECOVERY_MS;
_deferred = due && !recovery_safe;
if (!due || !recovery_safe) return Action::None;
if (_stage == 0) return Action::SoftRecovery;
if (_now - _stage_since < RECOVERY_GRACE_MS) return Action::None;
if (_stage == 1) return Action::Reenumerate;
// Cheap USB recovery does not move the original board-reboot deadline.
// Auto can attempt USB-only repair without ever authorizing an MCU reset.
return reboot_allowed && _now - _fault_since >= intervalMs(_step)
? Action::Reboot : Action::None;
}
// A Deferred backend result must not call this: it is retried by the caller.
// Attempted means requested, not proven healthy. Only observed completion
// can clear a previously stalled transport after destructive cleanup.
void actionAttempted(Action action) {
if (action == Action::SoftRecovery) {
_need_progress = _need_progress || _stalled;
_stage = 1;
_stage_since = _now;
} else if (action == Action::Reenumerate) {
_stage = 2;
_stage_since = _now;
} else if (action == Action::Reboot) {
_stage = 3;
}
}
// Call only after the next retry tier was made durable. A failed commit
// must never authorize a reboot using the old one-hour tier.
void backoffCommitted(uint8_t step) {
_step = step <= MAX_STEP ? step : MAX_STEP;
}
void restartInterval() {
_stage = 0;
_fault_since = _now;
_need_progress = false;
}
uint8_t backoffStep() const { return _step; }
uint8_t stage() const { return _stage; }
bool stalled() const { return _stalled || _need_progress; }
bool deferred() const { return _deferred; }
uint32_t inactiveSeconds() const {
const uint64_t seconds = _fault ? (_now - _fault_since) / 1000 : 0;
return seconds > UINT32_MAX ? UINT32_MAX : uint32_t(seconds);
}
private:
void tick(uint32_t now) {
if (!_clock_started) { begin(now, _step); return; }
const uint32_t delta = now - _last_tick;
// Ignore small out-of-order clock samples, while extending normal
// millis rollover to a monotonic clock for nodes up longer than 49 days.
if (delta > 0x80000000UL && uint32_t(_last_tick - now) <= 5000) return;
_now += delta;
_last_tick = now;
}
void clearTracking() {
_fault = false;
_healthy = false;
_observed = false;
_stalled = false;
_need_progress = false;
_deferred = false;
_stage = 0;
_fault_since = _pending_since = _healthy_since = _stage_since = _now;
}
uint64_t _now = 0, _fault_since = 0, _pending_since = 0;
uint64_t _healthy_since = 0, _stage_since = 0;
uint32_t _last_tick = 0, _progress = 0;
uint8_t _step = 0, _stage = 0;
bool _clock_started = false, _observed = false, _fault = false, _healthy = false;
bool _active = false;
bool _stalled = false, _need_progress = false, _deferred = false;
};
// Auto is a continuous qualification, not accumulated occasional cable use.
// Its timer is intentionally volatile across reboot: a cold start cannot prove
// that the logging reader stayed connected while the MCU was down.
class UsbLoggingAutoArmPolicy {
public:
static constexpr uint32_t QUALIFICATION_MS = 1209600000UL; // 14 days
void begin(uint32_t now) {
_now = now;
_last_tick = now;
_started = true;
_qualifying = false;
_since = _now;
}
bool update(uint32_t now, bool qualifying, bool auto_mode) {
if (!_started) begin(now);
const uint32_t delta = now - _last_tick;
if (!(delta > 0x80000000UL && uint32_t(_last_tick - now) <= 5000)) {
_now += delta;
_last_tick = now;
}
if (!auto_mode || !qualifying) {
_qualifying = false;
_since = _now;
return false;
}
if (!_qualifying) { _qualifying = true; _since = _now; }
return _now - _since >= QUALIFICATION_MS;
}
uint32_t connectedSeconds() const {
const uint64_t seconds = _qualifying ? (_now - _since) / 1000 : 0;
return seconds > UINT32_MAX ? UINT32_MAX : uint32_t(seconds);
}
private:
uint64_t _now = 0, _since = 0;
uint32_t _last_tick = 0;
bool _started = false, _qualifying = false;
};
} // namespace mesh
+1 -1
View File
@@ -270,7 +270,7 @@ static unsigned long s_wifi_disconnect_time = 0;
// #region agent log
static void agentLogHeap(const char* location, const char* message, const char* hypothesisId,
size_t free_h, size_t max_alloc, unsigned long internal_free, unsigned long spiram_free) {
if (!mesh::isUsbLoggingEnabled()) return;
if (!mesh::isUsbDebugLoggingEnabled()) return;
char buf[320];
snprintf(buf, sizeof(buf),
"{\"sessionId\":\"debug-session\",\"location\":\"%s\",\"message\":\"%s\",\"hypothesisId\":\"%s\","
+2 -2
View File
@@ -36,8 +36,8 @@ class MeshSNMPAgent; // Forward declaration
// USB CDC-aware debug macros: only print if Serial is ready (non-blocking check)
// Serial.availableForWrite() returns bytes available in write buffer (>0 means ready)
// This prevents hangs when USB CDC isn't ready yet (e.g., ESP32-S3 native USB)
#define MQTT_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("MQTT: " F, ##__VA_ARGS__); } } while(0)
#define MQTT_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("MQTT: " F "\n", ##__VA_ARGS__); } } while(0)
#define MQTT_DEBUG_PRINT(F, ...) do { if (mesh::isUsbDebugLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("MQTT: " F, ##__VA_ARGS__); } } while(0)
#define MQTT_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbDebugLoggingEnabled() && mesh::usbLoggingPort().availableForWrite() > 0) { mesh::usbLoggingPort().printf("MQTT: " F "\n", ##__VA_ARGS__); } } while(0)
#else
#define MQTT_DEBUG_PRINT(...) {}
#define MQTT_DEBUG_PRINTLN(...) {}
+2 -2
View File
@@ -71,8 +71,8 @@ public:
#if ESPNOW_DEBUG_LOGGING && ARDUINO
#include <Arduino.h>
#define ESPNOW_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("ESP-Now: " F, ##__VA_ARGS__); } } while(0)
#define ESPNOW_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("ESP-Now: " F "\n", ##__VA_ARGS__); } } while(0)
#define ESPNOW_DEBUG_PRINT(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("ESP-Now: " F, ##__VA_ARGS__); } } while(0)
#define ESPNOW_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("ESP-Now: " F "\n", ##__VA_ARGS__); } } while(0)
#else
#define ESPNOW_DEBUG_PRINT(...) {}
#define ESPNOW_DEBUG_PRINTLN(...) {}
+2 -2
View File
@@ -196,8 +196,8 @@ public:
#if BLE_DEBUG_LOGGING && ARDUINO
#include <Arduino.h>
#define BLE_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F, ##__VA_ARGS__); } } while(0)
#define BLE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F "\n", ##__VA_ARGS__); } } while(0)
#define BLE_DEBUG_PRINT(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F, ##__VA_ARGS__); } } while(0)
#define BLE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F "\n", ##__VA_ARGS__); } } while(0)
#else
#define BLE_DEBUG_PRINT(...) {}
#define BLE_DEBUG_PRINTLN(...) {}
+2 -2
View File
@@ -55,7 +55,7 @@ void TBeamBoard::scanDevices(TwoWire *w)
uint8_t err, addr;
int nDevices = 0;
uint32_t start = 0;
const bool usb_logging = mesh::isUsbLoggingEnabled();
const bool usb_logging = mesh::isUsbDebugLoggingEnabled();
if (usb_logging) Serial.println("Scanning I2C for Devices");
for (addr = 1; addr < 127; addr++) {
@@ -120,7 +120,7 @@ void TBeamBoard::scanDevices(TwoWire *w)
}
void TBeamBoard::printPMU()
{
if (!mesh::isUsbLoggingEnabled()) return;
if (!mesh::isUsbDebugLoggingEnabled()) return;
Serial.print("isCharging:"); Serial.println(PMU->isCharging() ? "YES" : "NO");
Serial.print("isDischarge:"); Serial.println(PMU->isDischarge() ? "YES" : "NO");
Serial.print("isVbusIn:"); Serial.println(PMU->isVbusIn() ? "YES" : "NO");
+27 -27
View File
@@ -826,13 +826,13 @@ bool WebConfigServer::startSetupMode(char reply[]) {
const esp_err_t sta_protocol_result = esp_wifi_set_protocol(
WIFI_IF_STA, mesh::wifi::kProtocolMask);
if (ap_protocol_result == ESP_OK && sta_protocol_result == ESP_OK) break;
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WebConfig protocol reset failed: AP=%d STA=%d\n",
(int)ap_protocol_result, (int)sta_protocol_result);
ap_ok = false;
}
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WebConfig AP attempt %u failed: mode_ok=%d disconnect_ok=%d mode=%d heap=%u largest=%u\n",
(unsigned)attempt, mode_ok, disconnect_ok, (int)WiFi.getMode(),
(unsigned)ESP.getFreeHeap(),
@@ -1038,7 +1038,7 @@ void WebConfigServer::tick(uint32_t now) {
break;
case WebConfigBatch::StopAction::Warn:
_stop_warned = true;
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WC: stop waiting for %lu handler(s); retaining session safely\n",
(unsigned long)refs);
break;
@@ -1067,11 +1067,11 @@ void WebConfigServer::tick(uint32_t now) {
createServer();
_connect_deadline = 0;
_last_activity = now;
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WebConfig ready: http://%s/\n",
WiFi.localIP().toString().c_str());
} else if (_connect_deadline && (int32_t)(now - _connect_deadline) >= 0) {
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WebConfig: WiFi '%s' unavailable; opening setup AP\n", _wifi_ssid);
const bool retry_saved_wifi = _wifi_ssid[0] != 0;
// Keep the WiFi driver running while changing from STA to AP+STA.
@@ -1085,7 +1085,7 @@ void WebConfigServer::tick(uint32_t now) {
if (startSetupMode(ignored)) {
_retry_saved_wifi_in_setup = retry_saved_wifi;
}
mesh::usbLoggingPort().println(ignored);
mesh::usbDebugPort().println(ignored);
}
return;
}
@@ -1101,7 +1101,7 @@ void WebConfigServer::tick(uint32_t now) {
_wifi_reconnect_tracker.noteDisconnected(now);
if (_wifi_reconnect_tracker.retryDue(now)) {
_wifi_reconnect_tracker.noteAttempt(now);
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WebConfig: WiFi still unavailable; retrying '%s'\n",
_wifi_ssid);
WiFi.mode(WIFI_STA);
@@ -1139,7 +1139,7 @@ void WebConfigServer::tick(uint32_t now) {
_wifi_reconnect_tracker.noteConnected();
_mode = MODE_LAN;
_last_activity = now;
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WebConfig: saved WiFi recovered; ready at http://%s/\n",
WiFi.localIP().toString().c_str());
} else if (_owns_wifi && _setup_reconnect_in_progress
@@ -1147,14 +1147,14 @@ void WebConfigServer::tick(uint32_t now) {
WiFi.disconnect(false, false);
_setup_reconnect_in_progress = false;
_setup_reconnect_deadline = 0;
mesh::usbLoggingPort().println(
mesh::usbDebugPort().println(
"WebConfig: saved WiFi still unavailable; setup AP remains active");
} else if (_owns_wifi && !_setup_reconnect_in_progress
&& _wifi_reconnect_tracker.retryDue(now)) {
_wifi_reconnect_tracker.noteAttempt(now);
_setup_reconnect_in_progress = true;
_setup_reconnect_deadline = now + 20000UL;
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WebConfig: retrying saved WiFi '%s'\n", _wifi_ssid);
mesh::wifi::beginStation(_wifi_ssid, _wifi_password);
}
@@ -1172,7 +1172,7 @@ void WebConfigServer::tick(uint32_t now) {
// Consume this request first so a full filesystem cannot turn tick() into
// a tight loop of repeated flush attempts.
_reboot_at = 0;
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WC: rebooting now (%s)\n",
_batch_reboot_armed ? "confirmed" : "fallback");
_cb->rebootNow();
@@ -1180,7 +1180,7 @@ void WebConfigServer::tick(uint32_t now) {
if ((int32_t)(_diag_until - now) > 0 && (now - _diag_last) >= 1000) {
_diag_last = now;
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WC: diag sta=%d heap=%u batch=%d/%d state=%d\n",
(int)WiFi.softAPgetStationNum(), (unsigned)ESP.getFreeHeap(),
(int)_batch_next, (int)_batch_count, (int)_batch_state);
@@ -1201,7 +1201,7 @@ void WebConfigServer::tick(uint32_t now) {
_mode == MODE_SETUP, _wifi_ssid[0] != 0, now,
_setup_started_at,
(uint32_t)WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS)) {
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WebConfig: WiFi still unconfigured after %lu minutes; powering off until reboot or explicit restart\n",
(unsigned long)((uint32_t)WEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS / 60000UL));
requestStop();
@@ -1232,7 +1232,7 @@ void WebConfigServer::tick(uint32_t now) {
_setup_reconnect_deadline = 0;
_mode = MODE_LAN;
_last_activity = now;
mesh::usbLoggingPort().println(
mesh::usbDebugPort().println(
"WebConfig: setup AP idle; saved WiFi recovery continues");
} else {
requestStop();
@@ -1268,7 +1268,7 @@ void WebConfigServer::serviceSetupWiFiHandoff(uint32_t now) {
_setup_wifi_handoff_pending = false;
_setup_wifi_handoff_deadline = 0;
}
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WebConfig: joined '%s' at %s; waiting for browser handoff\n",
_wifi_ssid, ip);
finishBatch(now);
@@ -1297,7 +1297,7 @@ void WebConfigServer::serviceSetupWiFiHandoff(uint32_t now) {
_setup_wifi_handoff_deadline = 0;
_setup_wifi_handoff_ip[0] = 0;
}
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WebConfig: could not join '%s'; setup AP remains active\n",
_wifi_ssid);
finishBatch(now);
@@ -1404,11 +1404,11 @@ void WebConfigServer::drainBatch(uint32_t now) {
// `set wifi.pwd` or `password` from the terminal must not reach the serial
// log, which is a different audience from the browser session.
if (_batch_kind == BATCH_CLI) {
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WC: cli %d/%d took %lums\n", (int)_batch_next,
(int)_batch_count, (unsigned long)(_batch_last_cmd - t0));
} else {
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WC: cmd %d/%d '%s' took %lums\n", (int)_batch_next,
(int)_batch_count, e.key,
(unsigned long)(_batch_last_cmd - t0));
@@ -1461,7 +1461,7 @@ void WebConfigServer::drainBatch(uint32_t now) {
// that will be shown to the operator before the setup AP is shut down.
WiFi.mode(WIFI_AP_STA);
WiFi.setAutoReconnect(false);
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WebConfig: testing saved WiFi '%s' before reboot\n", _wifi_ssid);
mesh::wifi::beginStation(_wifi_ssid, _wifi_password);
return;
@@ -1477,7 +1477,7 @@ void WebConfigServer::drainBatch(uint32_t now) {
// Distinguishes "client stopped sending" from "server stopped accepting" when
// a save's confirmation polls go missing on hardware.
static void wcLogReq(AsyncWebServerRequest* r) {
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WC: http %s %s\n", r->methodToString(), r->url().c_str());
}
@@ -2102,7 +2102,7 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
uint32_t du = millis() + 60000;
if (du == 0) du = 1;
_diag_until = du;
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WC: config POST accepted, %d cmds, reboot=%d\n",
count, (int)reboot_after);
@@ -2117,12 +2117,12 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) {
mesh::usbLoggingPort().println("WC: result read -> 503 (mode off)");
mesh::usbDebugPort().println("WC: result read -> 503 (mode off)");
req->send(503);
return;
}
if (!checkAuth(req)) {
mesh::usbLoggingPort().println("WC: result read -> 401");
mesh::usbDebugPort().println("WC: result read -> 401");
req->send(401, "application/json", "{\"error\":\"auth\"}");
return;
}
@@ -2138,7 +2138,7 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
// Entry print BEFORE the lock (racy state read is fine for diag): if this
// fires but no branch print follows, the handler is blocked on _mux.
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WC: result entry mode=%d state=%d\n",
(int)_mode, (int)_batch_state);
WCLock lock(_mux);
@@ -2150,7 +2150,7 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
const WebConfigBatch::ResultOutcome outcome =
WebConfigBatch::classifyResult(toSpecState(_batch_state), mine);
if (outcome == WebConfigBatch::ResultOutcome::Idle) {
mesh::usbLoggingPort().println("WC: result read -> idle");
mesh::usbDebugPort().println("WC: result read -> idle");
StaticJsonDocument<64> idle;
idle["state"] = "idle";
idle["reqid"] = requested_reqid;
@@ -2172,7 +2172,7 @@ void WebConfigServer::handleConfigResult(AsyncWebServerRequest* req) {
req->send(200, "application/json", out);
return;
}
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WC: result read -> done (reboot=%d armed=%d all_ok=%d)\n",
(int)_batch_reboot, (int)_batch_reboot_armed,
(int)_batch_all_ok);
@@ -2557,7 +2557,7 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
strncpy(_batch_reqid, reqid, sizeof(_batch_reqid) - 1);
_batch_reqid[sizeof(_batch_reqid) - 1] = 0;
_batch_state = BATCH_PENDING; // tick() picks it up on the loop task
mesh::usbLoggingPort().printf(
mesh::usbDebugPort().printf(
"WC: cli POST accepted, %d cmds, reboot=%d\n",
count, (int)defer_reboot);
@@ -92,9 +92,9 @@ class SerialEthernetInterface : public BaseSerialInterface {
#if ETHERNET_DEBUG_LOGGING && ARDUINO
#include <Arduino.h>
#define ETHERNET_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("ETH: " F, ##__VA_ARGS__); } } while(0)
#define ETHERNET_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("ETH: " F "\n", ##__VA_ARGS__); } } while(0)
#define ETHERNET_DEBUG_PRINT_IP(name, ip) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("ETH: " name ": %u.%u.%u.%u" "\n", ip[0], ip[1], ip[2], ip[3]); } } while(0)
#define ETHERNET_DEBUG_PRINT(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("ETH: " F, ##__VA_ARGS__); } } while(0)
#define ETHERNET_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("ETH: " F "\n", ##__VA_ARGS__); } } while(0)
#define ETHERNET_DEBUG_PRINT_IP(name, ip) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("ETH: " name ": %u.%u.%u.%u" "\n", ip[0], ip[1], ip[2], ip[3]); } } while(0)
#else
#define ETHERNET_DEBUG_PRINT(...) {}
#define ETHERNET_DEBUG_PRINTLN(...) {}
+2 -2
View File
@@ -222,8 +222,8 @@ public:
#if BLE_DEBUG_LOGGING && ARDUINO
#include <Arduino.h>
#define BLE_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F, ##__VA_ARGS__); } } while(0)
#define BLE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F "\n", ##__VA_ARGS__); } } while(0)
#define BLE_DEBUG_PRINT(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F, ##__VA_ARGS__); } } while(0)
#define BLE_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("BLE: " F "\n", ##__VA_ARGS__); } } while(0)
#else
#define BLE_DEBUG_PRINT(...) {}
#define BLE_DEBUG_PRINTLN(...) {}
+2 -2
View File
@@ -1,12 +1,12 @@
#pragma once
// Opt-in OTA tracing over Serial: build with -D OTA_DEBUG to watch the fetch (ADV/REQ/block/page-flush)
// during bring-up. Compiles to nothing otherwise, and on the native host (no Arduino), so it never
// during bring-up, with usb.logging and usb.debug enabled. Compiles to nothing otherwise, and on the native host (no Arduino), so it never
// touches a non-debug or test build.
#if defined(OTA_DEBUG) && defined(ARDUINO)
#include <Arduino.h>
#include <helpers/UsbLogging.h>
#define OTA_DBG(...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf(__VA_ARGS__); } } while (0)
#define OTA_DBG(...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf(__VA_ARGS__); } } while (0)
#else
#define OTA_DBG(...) do {} while (0)
#endif
+1 -1
View File
@@ -12,7 +12,7 @@ static inline bool airohaEnterSleep(LocationProvider* nmea) {
nmea->sendSentence("$PAIR650,0");
if (nmea->waitFor("$PAIR001,650,0", 50)) { // wait for the command received signal
#ifdef GPS_NMEA_DEBUG
if (mesh::isUsbLoggingEnabled()) {
if (mesh::isUsbDebugLoggingEnabled()) {
mesh::usbLoggingPort().printf("Airoha RTC Backup sleep command accepted by GPS after %u attempts\r\n", attempt + 1);
}
#endif
@@ -213,7 +213,7 @@ public :
if (_gps_serial->available()) {
char c = _gps_serial->read();
#ifdef GPS_NMEA_DEBUG
if (mesh::isUsbLoggingEnabled()) mesh::usbLoggingPort().print(c);
if (mesh::isUsbDebugLoggingEnabled()) mesh::usbLoggingPort().print(c);
#endif
// MicroNMEA also returns true for a complete sentence with a
// bad checksum. A damaged ACK must not confirm GPS sleep.
@@ -235,7 +235,7 @@ public :
_last_uart_ms = millis();
_uart_seen = true;
#ifdef GPS_NMEA_DEBUG
if (mesh::isUsbLoggingEnabled()) mesh::usbLoggingPort().print(c);
if (mesh::isUsbDebugLoggingEnabled()) mesh::usbLoggingPort().print(c);
#endif
bool parsed = nmea.process(c);
processed++;
+13 -10
View File
@@ -2,6 +2,7 @@
#include <Arduino.h>
#include <Wire.h>
#include <helpers/UsbLogging.h>
#include "TouchTapDetector.h"
@@ -36,17 +37,18 @@ public:
#endif
if (_present) {
Serial.printf("Touch: CHSC6X found at 0x%02X\n", CHSC6X_I2C_ADDR);
} else {
if (mesh::isUsbDebugLoggingEnabled())
mesh::usbDebugPort().printf("Touch: CHSC6X found at 0x%02X\n", CHSC6X_I2C_ADDR);
} else if (mesh::isUsbDebugLoggingEnabled()) {
// Report what is actually on the bus, so an unexpected controller or
// address can be identified from a normal boot log.
Serial.printf("Touch: idle or absent at 0x%02X; I2C bus currently answers:",
mesh::usbDebugPort().printf("Touch: idle or absent at 0x%02X; I2C bus currently answers:",
CHSC6X_I2C_ADDR);
for (uint8_t addr = 8; addr < 0x78; addr++) {
_wire->beginTransmission(addr);
if (_wire->endTransmission() == 0) Serial.printf(" 0x%02X", addr);
if (_wire->endTransmission() == 0) mesh::usbDebugPort().printf(" 0x%02X", addr);
}
Serial.println();
mesh::usbDebugPort().println();
}
return _present;
}
@@ -58,7 +60,7 @@ public:
bool pressed = readPressed();
if (pressed && !_present) {
_present = true; // answered late; the boot probe caught it mid-idle
Serial.println("Touch: CHSC6X responding");
mesh::usbDebugPort().println("Touch: CHSC6X responding");
}
return _detector.update(now_ms, pressed);
}
@@ -104,21 +106,22 @@ private:
// frames are unconditional so an idle read that never changes is still
// visible in the log.
void logRaw(uint8_t got, const uint8_t* buf) {
if (!mesh::isUsbDebugLoggingEnabled()) return;
int16_t key = buf ? (int16_t)buf[0] : (int16_t)(-2 - (int16_t)got);
if (key == _logged && _log_budget == 0) return;
if (_log_budget > 0) _log_budget--;
_logged = key;
if (!buf) {
Serial.printf("Touch: short read (%u of %u bytes)\n", got, CHSC6X_READ_LEN);
mesh::usbDebugPort().printf("Touch: short read (%u of %u bytes)\n", got, CHSC6X_READ_LEN);
return;
}
Serial.printf("Touch: raw %02X %02X %02X %02X %02X", buf[0], buf[1], buf[2], buf[3], buf[4]);
mesh::usbDebugPort().printf("Touch: raw %02X %02X %02X %02X %02X", buf[0], buf[1], buf[2], buf[3], buf[4]);
#ifdef PIN_TOUCH_INT
// Pulled up, so an unfitted R13 sits steady HIGH and a wired INT pulses LOW.
Serial.printf(" INT=%d", digitalRead(PIN_TOUCH_INT));
mesh::usbDebugPort().printf(" INT=%d", digitalRead(PIN_TOUCH_INT));
#endif
Serial.println();
mesh::usbDebugPort().println();
}
#else
void logRaw(uint8_t, const uint8_t*) {}
+2 -2
View File
@@ -89,8 +89,8 @@ public:
#if WIFI_DEBUG_LOGGING && ARDUINO
#include <Arduino.h>
#define WIFI_DEBUG_PRINT(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("WiFi: " F, ##__VA_ARGS__); } } while(0)
#define WIFI_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbLoggingEnabled()) { mesh::usbLoggingPort().printf("WiFi: " F "\n", ##__VA_ARGS__); } } while(0)
#define WIFI_DEBUG_PRINT(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("WiFi: " F, ##__VA_ARGS__); } } while(0)
#define WIFI_DEBUG_PRINTLN(F, ...) do { if (mesh::isUsbDebugLoggingEnabled()) { mesh::usbLoggingPort().printf("WiFi: " F "\n", ##__VA_ARGS__); } } while(0)
#else
#define WIFI_DEBUG_PRINT(...) {}
#define WIFI_DEBUG_PRINTLN(...) {}
@@ -8,6 +8,7 @@ using esp_event_handler_t = void (*)(void*, esp_event_base_t, int32_t, void*);
constexpr int ARDUINO_USB_CDC_ANY_EVENT = -1;
constexpr int ARDUINO_USB_CDC_DISCONNECTED_EVENT = 1;
constexpr int ARDUINO_USB_CDC_LINE_STATE_EVENT = 2;
constexpr int ARDUINO_USB_CDC_TX_EVENT = 3;
struct arduino_usb_cdc_event_data_t {
struct { bool dtr; bool rts; } line_state{};
};
@@ -35,6 +36,7 @@ class MockSerial : public Stream {
assert(false && "mode0 must never call USBCDC::flush");
}
void setDebugOutput(bool enabled) { debug_enabled = enabled; }
void setTxTimeoutMs(unsigned timeout) { tx_timeout_ms = timeout; }
void onEvent(int event, esp_event_handler_t handler) {
assert(event == ARDUINO_USB_CDC_ANY_EVENT);
callback = handler;
@@ -44,6 +46,7 @@ class MockSerial : public Stream {
int rx_count = 0;
int registrations = 0;
bool debug_enabled = true;
unsigned tx_timeout_ms = 99;
esp_event_handler_t callback = nullptr;
};
@@ -7,3 +7,6 @@ uint32_t tud_cdc_n_write_available(uint8_t instance);
uint32_t tud_cdc_n_write(uint8_t instance, const void* data, uint32_t size);
uint32_t tud_cdc_n_write_flush(uint8_t instance);
bool tud_cdc_n_write_clear(uint8_t instance);
bool tud_mounted();
bool tud_connect();
bool tud_disconnect();
@@ -3,10 +3,15 @@
#include <iostream>
#include <string>
#include "helpers/UsbLogging.h"
#include "helpers/UsbLoggingClientActivity.h"
#include "MeshCore.h"
#include "helpers/SerialPacketLog.h"
MockSerial Serial;
static bool connected = true;
static bool mounted = true;
static unsigned disconnect_calls = 0;
static unsigned connect_calls = 0;
static bool auto_drain = false;
static std::string fifo;
static std::string host;
@@ -15,6 +20,10 @@ static unsigned flush_calls = 0;
static unsigned clear_calls = 0;
static void (*during_write)() = nullptr;
bool tud_mounted() { return mounted; }
bool tud_disconnect() { ++disconnect_calls; mounted = false; connected = false; return true; }
bool tud_connect() { ++connect_calls; return true; }
bool tud_cdc_n_connected(uint8_t instance) {
assert(!mock_isr);
assert(instance == 0);
@@ -82,6 +91,50 @@ static void fresh_session() {
auto_drain = false;
}
static void check_logging_watchdog_transport_observations_and_recovery() {
fresh_session();
mesh::setUsbLoggingEnabled(true);
const auto initial = mesh::observeUsbLoggingTransport();
assert(initial.supported && initial.host_connected && initial.reader_connected);
assert(!initial.pending);
assert(!mesh::isUsbLoggingClientActive()); // An open CDC is not an app poll.
mesh::noteUsbLoggingStatsCommand("stats-core");
assert(mesh::isUsbLoggingClientActive());
fresh_session(); // Even a close/reopen between service calls revokes it.
assert(!mesh::isUsbLoggingClientActive());
mesh::noteUsbLoggingStatsCommand("stats-radio");
mesh::setUsbLoggingEnabled(false);
assert(!mesh::isUsbLoggingClientActive());
mesh::setUsbLoggingEnabled(true);
mesh::probeUsbLoggingTransport();
const auto queued = mesh::observeUsbLoggingTransport();
assert(queued.pending && queued.tx_progress == initial.tx_progress);
assert(mesh::recoverUsbLoggingTransport(1) == mesh::UsbLoggingRecoveryResult::Attempted);
// Purge and probe merely change queues; neither is host acknowledgement.
assert(mesh::observeUsbLoggingTransport().tx_progress == initial.tx_progress);
host += fifo; fifo.clear();
Serial.callback(nullptr, nullptr, ARDUINO_USB_CDC_TX_EVENT, nullptr);
const auto completed = mesh::observeUsbLoggingTransport();
assert(completed.tx_progress == initial.tx_progress + 1);
assert(!completed.pending);
const auto detached_at = millis();
assert(mesh::recoverUsbLoggingTransport(2) == mesh::UsbLoggingRecoveryResult::Attempted);
assert(disconnect_calls == 1 && connect_calls == 0);
assert(millis() == detached_at); // No blocking wait or descriptor rebuild.
g_mock_millis += 49;
mesh::serviceUsbLoggingPort();
assert(connect_calls == 0);
++g_mock_millis;
mesh::serviceUsbLoggingPort();
assert(connect_calls == 1 && !mesh::observeUsbLoggingTransport().host_connected);
mock_isr = true;
assert(mesh::recoverUsbLoggingTransport(1) == mesh::UsbLoggingRecoveryResult::Deferred);
mesh::probeUsbLoggingTransport();
mock_isr = false;
mounted = connected = true;
fresh_session();
}
static void check_native_short_writes_and_mota() {
const unsigned before = write_calls;
assert(put(mesh::usbCompanionPort(), std::string(200, 'B')) == 64);
@@ -150,6 +203,7 @@ static void check_disconnect_cleanup_keeps_new_host_input() {
}
static void check_debug_formatter_and_reentrancy() {
mesh::setUsbDebugEnabled(true);
auto_drain = true;
const std::string long_text(1000, 'D');
assert(mesh::nrf52DebugPrintf("%s\n", long_text.c_str()) == 255);
@@ -165,6 +219,39 @@ static void check_debug_formatter_and_reentrancy() {
drain_all();
assert(host == "outer\n");
fresh_session();
mesh::setUsbDebugEnabled(false);
}
static void check_independent_debug_gate() {
assert(!mesh::isUsbDebugEnabled());
assert(mesh::isUsbLoggingEnabled());
const auto before = write_calls;
MESH_DEBUG_PRINTLN("default-off debug");
assert(mesh::nrf52DebugPrintf("also quiet\n") == 0);
assert(write_calls == before && !mesh::hasPendingUsbTerminalOutput());
mesh::SerialLogLine<> raw;
raw.printf("12:34:56 - 3/10/2026 U RAW: 0102");
assert(raw.flush(mesh::usbLoggingPort()));
drain_all();
assert(host == "12:34:56 - 3/10/2026 U RAW: 0102\r\n");
fresh_session();
mesh::setUsbDebugEnabled(true);
assert(mesh::isUsbDebugEnabled() && mesh::isUsbDebugLoggingEnabled());
MESH_DEBUG_PRINTLN("explicit debug");
drain_all();
assert(host == "DEBUG: explicit debug\n");
fresh_session();
mesh::setUsbLoggingEnabled(false);
assert(mesh::isUsbDebugEnabled() && !mesh::isUsbDebugLoggingEnabled());
const auto gated = write_calls;
MESH_DEBUG_PRINTLN("master is off");
assert(write_calls == gated && !mesh::hasPendingUsbTerminalOutput());
mesh::setUsbLoggingEnabled(true);
assert(mesh::isUsbDebugLoggingEnabled());
mesh::setUsbDebugEnabled(false);
assert(mesh::isUsbLoggingEnabled() && !mesh::isUsbDebugLoggingEnabled());
assert(!Serial.debug_enabled); // native raw framework output stays forbidden
}
static void check_cached_logging_gate_and_isr() {
@@ -215,6 +302,8 @@ int main() {
assert(!Serial.debug_enabled);
mesh::serviceUsbLoggingPort();
assert(&mesh::usbConsolePort() == &mesh::usbTerminalPort());
check_logging_watchdog_transport_observations_and_recovery();
check_independent_debug_gate();
check_native_short_writes_and_mota();
check_ordered_text_and_functional_reserve();
check_stalled_host_and_visible_overflow();
@@ -227,6 +316,9 @@ int main() {
assert(&mesh::usbConsolePort() == &Serial);
assert(mesh::canAcceptUsbConsoleCommand());
assert(mesh::usbTerminalDroppedBytes() == 0);
assert(!mesh::observeUsbLoggingTransport().supported);
assert(mesh::recoverUsbLoggingTransport(1) == mesh::UsbLoggingRecoveryResult::Unsupported);
mesh::probeUsbLoggingTransport();
#endif
std::cout << "ESP32 TinyUSB transport checks passed\n";
}
+84 -13
View File
@@ -19,7 +19,7 @@ int main() {
assert(equal(tag, hex("85632d07c6e8f37f950acd320a2ecc93").data(), 16));
assert(p == hex("40c02b9690c4dc04daef7f6afe5c"));
assert(open(k.data(), ad.data(), ad.size(), p.data(), p.size(), tag)); assert(p == plain);
for (size_t n : {size_t(0), size_t(1), size_t(15), size_t(16), size_t(17), size_t(78), size_t(177)}) {
for (size_t n : {size_t(0), size_t(1), size_t(15), size_t(16), size_t(17), size_t(78), size_t(177), MAX_PAYLOAD}) {
std::vector<uint8_t> b(n, 0x39), original = b;
assert(seal(k.data(), ad.data(), ad.size(), b.data(), n, tag));
assert(open(k.data(), ad.data(), ad.size(), b.data(), n, tag)); assert(b == original);
@@ -27,7 +27,7 @@ int main() {
assert(!open(k.data(), ad.data(), ad.size(), b.data(), n, tag));
for (uint8_t c : b) assert(c == 0);
}
assert(!seal(k.data(), ad.data(), ad.size(), p.data(), 178, tag));
assert(!seal(k.data(), ad.data(), ad.size(), p.data(), MAX_PAYLOAD + 1, tag));
History history;
history.sample(0, 3900, -4); history.sample(59, 3100, 98);
assert(history.week().voltage == 3100 && history.week().high == temperature(98));
@@ -58,18 +58,89 @@ int main() {
assert(acl.count == 1 && acl.entries[0][12] == 3);
for (uint8_t i = 1; i < MAX_KEYS; ++i) { token[0] = i; assert(acl.add(token, ADMIN)); }
token[0] = 240; assert(!acl.add(token, ADMIN)); assert(acl.pages() == MAX_PAGES);
// Emit cross-language packets with all header fields and maximum ACL pages.
for (uint8_t page = 0; page < acl.pages(); ++page) {
uint8_t raw[MAX_PAYLOAD] = {}, enc[32]; memcpy(raw, "MGR1", 4); memcpy(raw + 4, radio, 16);
write32(raw + 20, 42); write32(raw + 28, 0x01110105); write16(raw + 76, FIRMWARE);
raw[71] = 5; raw[78] = page; raw[79] = acl.pages(); raw[80] = acl.count;
raw[81] = page * PER_PAGE; raw[82] = PER_PAGE;
memcpy(raw + HEADER, acl.entries[page * PER_PAGE], PER_PAGE * ENTRY);
mesh::UsbLoggingStatus usb;
usb.supported = usb.logging_enabled = usb.watchdog_enabled = true;
usb.host_connected = usb.reader_connected = usb.logger_active = true;
usb.stalled = usb.recovering = usb.recovery_deferred = usb.persistence_ready = true;
usb.watchdog_auto = true; usb.auto_connected_seconds = 1209600;
usb.stage = 2; usb.backoff_step = 8; usb.retry_seconds = 604800;
usb.inactive_seconds = UINT32_MAX;
usb.last_event.reasons = 15;
usb.last_event.action = mesh::UsbLoggingWatchdogEvent::REBOOT_REQUESTED;
usb.last_event.epoch = 1700000001; usb.last_event.uptime_seconds = 777;
usb.last_event.sequence = 42; usb.last_event.persisted = true;
uint8_t usb_block[USB_STATUS_SIZE] = {};
encodeUsbStatus(usb_block, usb);
assert(read16(usb_block) == 2047 && usb_block[2] == (2 | (8 << 2)));
assert(read32(usb_block + 3) == 604800 && read32(usb_block + 7) == UINT32_MAX);
assert(read32(usb_block + 11) == 1209600);
usb.logger_active = false; encodeUsbStatus(usb_block, usb);
assert(read16(usb_block) == 1023); // host/reader connection does not imply a logger
usb.logger_active = true;
// Emit all wire versions for independent Python and browser decoders.
for (const char* magic : {"MGR1", "MGR2", "MGR3"}) {
const auto* version = reinterpret_cast<const uint8_t*>(magic);
const bool current = currentPage(version), has_usb = usbPage(version);
const size_t header = headerSize(version), per_page = entriesPerPage(version);
for (uint8_t page = 0; page < acl.pages(per_page); ++page) {
uint8_t raw[179] = {}, enc[32]; memcpy(raw, magic, 4);
memcpy(raw + 4, radio, 16);
write32(raw + 20, 42); write32(raw + 28, 0x01110105); write16(raw + 76, FIRMWARE);
raw[71] = 5; raw[78] = page; raw[79] = acl.pages(per_page); raw[80] = acl.count;
raw[81] = page * per_page;
raw[82] = acl.count - raw[81] < per_page ? acl.count - raw[81] : per_page;
if (has_usb) encodeUsbStatus(raw + HEADER, usb);
if (current) mesh::encodeUsbWatchdogEvent(raw + WATCHDOG_EVENT_OFFSET, usb.last_event);
const size_t private_len = raw[82] * ENTRY, size = header + private_len + TAG;
memcpy(raw + header, acl.entries[raw[81]], private_len);
deriveKey(root, "MeshCore-MGR1-SIV", radio, enc);
assert(seal(enc, raw, header, raw + header, private_len, raw + header + private_len));
assert(validPage(raw, size)); print(raw, size);
assert(size <= MAX_PAYLOAD && floodSize(size) <= 179);
assert(validPage(raw, floodSize(size), true));
if (floodSize(size) > size) {
raw[floodSize(size) - 1] ^= 1;
assert(!validPage(raw, floodSize(size), true)); raw[floodSize(size) - 1] ^= 1;
}
if (has_usb) {
raw[85] |= 0x40; assert(!validPage(raw, size)); raw[85] &= ~0x40;
raw[84] |= 0x08; assert(!validPage(raw, size)); raw[84] &= ~0x08;
}
if (current) {
const uint8_t original = raw[98];
for (uint8_t invalid : {uint8_t(original & 0xf0), uint8_t(original & 0x8f), uint8_t(0xdf)}) {
raw[98] = invalid; assert(!validPage(raw, size));
}
raw[98] = original;
write32(raw + 107, 0); assert(!validPage(raw, size)); write32(raw + 107, 42);
}
raw[81]++; assert(!validPage(raw, size)); raw[81]--;
assert(!validPage(raw, size - 1)); raw[80] = 37; assert(!validPage(raw, size));
}
}
// A current report with no recorded event is authenticated, not invented.
{
uint8_t raw[179] = {}, enc[32]; memcpy(raw, "MGR3", 4); memcpy(raw + 4, radio, 16);
write32(raw + 20, 43); raw[79] = 1;
deriveKey(root, "MeshCore-MGR1-SIV", radio, enc);
assert(seal(enc, raw, HEADER, raw + HEADER, PER_PAGE * ENTRY, raw + MAX_PAYLOAD - TAG));
assert(validPage(raw, sizeof(raw))); print(raw, sizeof(raw));
raw[81]++; assert(!validPage(raw, sizeof(raw))); raw[81]--;
assert(!validPage(raw, sizeof(raw) - 1)); raw[80] = 37; assert(!validPage(raw, sizeof(raw)));
assert(seal(enc, raw, CURRENT_HEADER, raw + CURRENT_HEADER, 0, raw + CURRENT_HEADER));
assert(validPage(raw, CURRENT_HEADER + TAG)); print(raw, CURRENT_HEADER + TAG);
}
// Every ACL count retains complete coverage and the unchanged flood ceiling.
for (unsigned total = 0; total <= MAX_KEYS; ++total) {
for (unsigned page = 0; page < (total ? (total + 3) / 4 : 1); ++page) {
uint8_t raw[179] = {}; memcpy(raw, "MGR3", 4);
raw[78] = page; raw[79] = total ? (total + 3) / 4 : 1; raw[80] = total;
raw[81] = page * 4; raw[82] = total - raw[81] < 4 ? total - raw[81] : 4;
const size_t size = pageSize(raw);
assert(validPage(raw, size) && floodSize(size) <= 179);
assert(validPage(raw, floodSize(size), true));
}
}
for (size_t n = 0; n < CURRENT_HEADER + TAG; ++n) {
std::vector<uint8_t> truncated(n);
for (size_t i = 0; i < n && i < 4; ++i) truncated[i] = "MGR3"[i];
assert(!validPage(truncated.data(), n));
}
for (size_t n = 0; n < HEADER + TAG; ++n) assert(!validPage(plain.data(), n));
}
+42 -3
View File
@@ -6,6 +6,11 @@
#include <string>
using namespace mesh;
using namespace mesh::management;
namespace mesh {
UsbLoggingStatus test_usb_status;
unsigned usb_status_calls = 0;
UsbLoggingStatus usbLoggingStatus() { ++usb_status_calls; return test_usb_status; }
}
struct Fixture {
MemoryFS fs;
Mesh mesh;
@@ -16,7 +21,7 @@ struct Fixture {
CommonCLI cli;
CommonCLICallbacks callbacks;
std::unique_ptr<ManagementReporter> reporter;
Fixture() { test_millis = 0; reboot(); }
Fixture() { test_millis = 0; test_usb_status = UsbLoggingStatus(); usb_status_calls = 0; reboot(); }
void reboot() { reporter.reset(); test_millis = 0; reporter.reset(new ManagementReporter(mesh, board, sensors, acl, prefs, callbacks, cli, &fs)); }
std::string cmd(const char* text, bool ok = true) {
char b[200], reply[160] = {}; strcpy(b, text);
@@ -100,23 +105,57 @@ int main() {
{
Fixture f;
for (unsigned i = 0; i < 36; ++i) { ClientInfo c; c.id.pub_key[0] = i; f.acl.clients.push_back(c); }
f.configure(false); f.advance(21 * DAY + 600); assert(f.mesh.packets.size() == 6);
for (unsigned i = 0; i < 6; ++i) {
test_usb_status.supported = true; test_usb_status.watchdog_enabled = true;
test_usb_status.host_connected = test_usb_status.reader_connected = true;
test_usb_status.stalled = true; test_usb_status.retry_seconds = 7200;
test_usb_status.inactive_seconds = 3600; test_usb_status.backoff_step = 1;
test_usb_status.last_event.reasons = UsbLoggingWatchdogEvent::TX_STALLED;
test_usb_status.last_event.action = UsbLoggingWatchdogEvent::REENUMERATE;
test_usb_status.last_event.sequence = 17; test_usb_status.last_event.uptime_seconds = 300;
test_usb_status.last_event.persisted = true;
f.configure(false); f.advance(21 * DAY + 600); assert(f.mesh.packets.size() == 9);
assert(usb_status_calls == 1);
for (unsigned i = 0; i < 9; ++i) {
const auto& p = f.mesh.packets[i]; assert(validPage(p.payload, p.payload_len));
assert(p.payload[78] == i && p.payload[80] == 36 && p.flood);
assert(!memcmp(p.payload, "MGR3", 4) && read16(p.payload + HEADER) == 61);
assert(!(read16(p.payload + HEADER) & 1024)); // an open reader is not an active logger
assert(read32(p.payload + HEADER + 3) == 7200);
assert(read32(p.payload + HEADER + 7) == 3600);
UsbLoggingWatchdogEvent event;
assert(decodeUsbWatchdogEvent(p.payload + WATCHDOG_EVENT_OFFSET, event));
assert(event.reasons == UsbLoggingWatchdogEvent::TX_STALLED && event.action == UsbLoggingWatchdogEvent::REENUMERATE);
assert(event.sequence == 17 && event.uptime_seconds == 300 && event.persisted && event.epoch == 0);
assert(floodSize(p.payload_len) <= 179);
}
}
{
Fixture f;
for (unsigned i = 0; i < 7; ++i) { ClientInfo c; c.id.pub_key[0] = i; f.acl.clients.push_back(c); }
f.configure(false); f.advance(21 * DAY); assert(f.mesh.packets.size() == 1);
assert(!memcmp(f.mesh.packets[0].payload, "MGR3", 4));
assert(read16(f.mesh.packets[0].payload + HEADER) == 0); // unsupported is explicit
test_usb_status.supported = true; test_usb_status.retry_seconds = 999;
test_usb_status.watchdog_auto = true; test_usb_status.auto_connected_seconds = 100;
test_usb_status.host_connected = test_usb_status.reader_connected = test_usb_status.logger_active = true;
test_usb_status.last_event.reasons = UsbLoggingWatchdogEvent::CLIENT_INACTIVE;
test_usb_status.last_event.action = UsbLoggingWatchdogEvent::SOFT_RECOVERY;
test_usb_status.last_event.sequence = 1; test_usb_status.last_event.epoch = 1700000001;
f.board.voltage = 2900; f.board.temperature = 60; f.advance(60);
assert(f.mesh.packets.size() == 2);
assert(read16(f.mesh.packets[1].payload + HEADER) == 0); // frozen report snapshot
assert(read32(f.mesh.packets[1].payload + HEADER + 11) == 0);
assert(read32(f.mesh.packets[1].payload + WATCHDOG_EVENT_OFFSET + 9) == 0);
assert(usb_status_calls == 1);
// The low reading was not in the first snapshot and must survive into the next one.
f.board.voltage = 3740; f.board.temperature = 24; f.advance(21 * DAY + 3600);
assert(f.mesh.packets.size() == 4);
assert(read16(f.mesh.packets.back().payload + 66) == 2900);
assert(f.mesh.packets.back().payload[69] == temperature(60));
assert(read16(f.mesh.packets.back().payload + HEADER) == 1561);
assert(read32(f.mesh.packets.back().payload + HEADER + 11) == 100);
assert(read32(f.mesh.packets.back().payload + WATCHDOG_EVENT_OFFSET + 9) == 1);
assert(usb_status_calls == 2);
}
{
Fixture f; f.configure(false); f.mesh.temp = true; f.advance(22 * DAY); assert(f.mesh.packets.empty());
@@ -0,0 +1,17 @@
#pragma once
#include <stdint.h>
#include "class/cdc/cdc.h"
#define CFG_TUD_CDC 1
#define CFG_TUD_CDC_TX_BUFSIZE 64
bool tud_mounted();
bool tud_connect();
bool tud_disconnect();
bool tud_cdc_n_connected(uint8_t);
uint32_t tud_cdc_n_available(uint8_t);
void tud_cdc_n_read_flush(uint8_t);
uint32_t tud_cdc_n_write_available(uint8_t);
uint32_t tud_cdc_n_write(uint8_t, const void*, uint32_t);
uint32_t tud_cdc_n_write_flush(uint8_t);
uint32_t tud_cdc_n_write_clear(uint8_t);
void tud_cdc_get_line_coding(cdc_line_coding_t*);
void TinyUSB_Port_EnterDFU();
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-parameter"
#pragma GCC diagnostic ignored "-Wsign-compare"
#include "../../../mocks/Arduino.h"
#pragma GCC diagnostic pop
#include <cassert>
uint32_t tud_cdc_n_write_available(uint8_t instance);
class MockSerial : public Stream {
public:
int available() override { return 0; }
int read() override { return -1; }
int peek() override { return -1; }
int availableForWrite() override { return tud_cdc_n_write_available(0); }
size_t write(const uint8_t*, size_t) override {
assert(false && "native nRF52 console must not use retrying Serial.write");
return 0;
}
void flush() override { assert(false && "console must not forward Serial.flush"); }
};
extern MockSerial Serial;
+3
View File
@@ -0,0 +1,3 @@
#pragma once
#include <stdint.h>
struct cdc_line_coding_t { uint32_t bit_rate; };
+131
View File
@@ -0,0 +1,131 @@
#include <Arduino.h>
#include <Adafruit_TinyUSB.h>
#include <helpers/UsbLogging.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <cassert>
#include <cstdio>
#include <functional>
#include <string>
MockSerial Serial;
static bool mounted = true, dtr = true, dfu = false;
static uint32_t baud = 115200;
static std::string fifo, host;
static std::function<void()> during_write;
extern "C" void tud_cdc_line_state_cb(uint8_t, bool, bool);
extern "C" void tud_cdc_line_coding_cb(uint8_t, const cdc_line_coding_t*);
extern "C" void tud_cdc_tx_complete_cb(uint8_t);
extern "C" void tud_umount_cb();
bool tud_mounted() { return mounted; }
bool tud_connect() { mounted = true; return true; }
bool tud_disconnect() { mounted = false; dtr = false; return true; }
bool tud_cdc_n_connected(uint8_t n) { assert(n == 0); return mounted && dtr; }
uint32_t tud_cdc_n_available(uint8_t n) { assert(n == 0); return 0; }
void tud_cdc_n_read_flush(uint8_t n) { assert(n == 0); }
uint32_t tud_cdc_n_write_available(uint8_t n) { assert(n == 0); return 64 - fifo.size(); }
uint32_t tud_cdc_n_write(uint8_t n, const void* data, uint32_t size) {
assert(n == 0 && size <= 64 - fifo.size());
if (during_write) {
auto callback = std::move(during_write);
during_write = nullptr;
callback();
}
fifo.append(static_cast<const char*>(data), size);
return size;
}
uint32_t tud_cdc_n_write_flush(uint8_t n) { assert(n == 0); return 0; }
uint32_t tud_cdc_n_write_clear(uint8_t n) { assert(n == 0); fifo.clear(); return 0; }
void tud_cdc_get_line_coding(cdc_line_coding_t* coding) { coding->bit_rate = baud; }
void TinyUSB_Port_EnterDFU() { dfu = true; }
static void service() {
mesh::serviceUsbLoggingPort();
mesh::serviceUsbTerminalPort();
(void)mesh::takeUsbTerminalSessionReset();
g_mock_millis += 8;
assert(mesh::tryCompleteUsbTerminalSessionReset());
}
static void drain() {
for (unsigned i = 0; i < 200; ++i) {
if (!fifo.empty()) {
host += fifo;
fifo.clear();
tud_cdc_tx_complete_cb(0);
}
service(); // Exercise the actual every-loop completion path too.
if (fifo.empty() && !mesh::hasPendingUsbTerminalOutput()) return;
}
assert(false && "finite queue did not drain");
}
int main() {
static_assert(MESH_USB_CONSOLE_COOPERATIVE, "ordinary native nRF52 must cooperate");
mesh::setUsbLoggingEnabled(true);
tud_cdc_line_state_cb(0, true, false);
service();
auto& console = mesh::usbConsolePort();
const std::string reply = "-> " + std::string(200, 'R') + "\r\n";
assert(console.write(reinterpret_cast<const uint8_t*>(reply.data()), reply.size()) == reply.size());
assert(mesh::hasPendingUsbTerminalOutput());
for (unsigned i = 0; i < 5; ++i) service();
assert(fifo == reply.substr(0, 64)); // Clean epochs must not clear the FIFO.
assert(mesh::usbLoggingPort().write(reinterpret_cast<const uint8_t*>("LOG\r\n"), 5) == 5);
mesh::probeUsbLoggingTransport();
drain();
assert(host == reply + "LOG\r\n[USB watchdog] heartbeat\r\n");
host.clear();
const std::string raw = "RAW: " + std::string(512, 'A') + "\r\n";
assert(mesh::usbLoggingPort().write(reinterpret_cast<const uint8_t*>(raw.data()), raw.size()) == raw.size());
drain();
assert(host == raw); // Shared-queue capacity must not drop >256-byte RAW.
host.clear();
uint8_t stale_printf[256];
std::fill(stale_printf, stale_printf + 255, 'Z');
stale_printf[255] = 0;
assert(mesh::usbLoggingPort().write(stale_printf, 512) == 0); // No OOB read.
during_write = [] {
dtr = false;
tud_cdc_line_state_cb(0, false, false);
dtr = true;
tud_cdc_line_state_cb(0, true, false);
(void)mesh::takeUsbTerminalSessionReset();
g_mock_millis += 8;
// The old producer still owns the buffer and native writer gate.
assert(!mesh::tryCompleteUsbTerminalSessionReset());
};
assert(console.write(reinterpret_cast<const uint8_t*>(reply.data()), reply.size()) == reply.size());
service();
drain();
assert(host.empty()); // Failed busy purge must be retried before reopening.
// A DTR-high host that stops reading never forces a retry/flush wait.
fifo.assign(64, 'X');
const std::string record(100, 'D');
for (unsigned i = 0; i < 100; ++i)
mesh::usbLoggingPort().write(reinterpret_cast<const uint8_t*>(record.data()), record.size());
assert(console.availableForWrite() >= 3072 && mesh::canAcceptUsbConsoleCommand());
const std::string huge(4096, 'F');
assert(console.write(reinterpret_cast<const uint8_t*>(huge.data()), huge.size()) == 0);
for (unsigned i = 0; i < 10000; ++i) mesh::serviceUsbTerminalPort();
assert(fifo.size() == 64); // No progress invented by queue admission.
assert(mesh::observeUsbLoggingTransport().pending);
mesh::noteUsbLoggingStatsCommand("stats-core");
assert(mesh::isUsbLoggingClientActive());
dtr = false;
tud_cdc_line_state_cb(0, false, false);
dtr = true;
tud_cdc_line_state_cb(0, true, false);
service();
assert(!mesh::isUsbLoggingClientActive() && !mesh::hasPendingUsbTerminalOutput());
drain();
assert(host.empty()); // No old response/log leaks across rapid reopen.
mesh::noteUsbLoggingStatsCommand("stats-radio");
mounted = false;
tud_umount_cb();
assert(!mesh::isUsbLoggingClientActive());
mounted = true; dtr = true;
tud_cdc_line_state_cb(0, true, false);
service();
baud = 1200; dtr = false;
tud_cdc_line_state_cb(0, false, false);
assert(dfu); // Existing bootloader entry is preserved.
puts("native nRF52 console transport passed");
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
inline uint32_t mock_watchdog_millis = 0;
inline uint32_t millis() { return mock_watchdog_millis; }
class Stream { public: virtual ~Stream() = default; };
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#include <algorithm>
#include <cerrno>
#include <cstdint>
#include <cstring>
#include <functional>
#include <map>
#include <string>
#include <vector>
namespace fs { class FS; }
class File {
public:
File() = default;
File(fs::FS* fs, std::string path, bool writable)
: _fs(fs), _path(std::move(path)), _writable(writable) {}
explicit operator bool() const { return _fs != nullptr; }
size_t size() const;
size_t read(uint8_t* buffer, size_t count);
size_t write(const uint8_t* buffer, size_t count);
void flush() {}
void close() { _fs = nullptr; }
private:
fs::FS* _fs = nullptr;
std::string _path;
size_t _offset = 0;
bool _writable = false;
};
namespace fs {
class FS {
public:
std::map<std::string, std::vector<uint8_t>> files;
std::string fail_stat, fail_open, short_read;
std::string fail_rename_from, fail_rename_to;
bool fail_write = false, fail_remove = false;
bool corrupt_final_image = false, fail_final_readback = false;
bool final_renamed = false;
unsigned writes = 0, write_opens = 0, renames = 0, removes = 0;
std::function<void()> after_final_rename;
bool exists(const char* path) const { return files.count(path) != 0; }
bool mkdir(const char*) { return true; }
File open(const char* path, const char* mode, bool = false) {
const bool writable = mode[0] == 'w';
if (fail_open == path || (!writable && fail_final_readback
&& final_renamed && std::string(path) == "/usb_wdg"))
return {};
if (writable) {
++write_opens;
files[path].clear();
} else if (!exists(path)) return {};
return File(this, path, writable);
}
bool remove(const char* path) {
++removes;
if (fail_remove) return false;
return files.erase(path) != 0;
}
bool rename(const char* from, const char* to) {
++renames;
if ((fail_rename_from == from && fail_rename_to == to)
|| !exists(from) || exists(to)) return false;
files[to] = std::move(files[from]);
files.erase(from);
if (std::string(from) == "/usb_wdg.tmp" && std::string(to) == "/usb_wdg") {
final_renamed = true;
if (corrupt_final_image) files[to][0] ^= 1;
if (after_final_rename) after_final_rename();
}
return true;
}
};
inline FS* stat_filesystem = nullptr;
} // namespace fs
inline size_t File::size() const {
return _fs ? _fs->files.at(_path).size() : 0;
}
inline size_t File::read(uint8_t* buffer, size_t count) {
if (!_fs || _writable) return 0;
const auto& bytes = _fs->files.at(_path);
size_t available = std::min(count, bytes.size() - _offset);
if (_fs->short_read == _path && available) --available;
std::memcpy(buffer, bytes.data() + _offset, available);
_offset += available;
return available;
}
inline size_t File::write(const uint8_t* buffer, size_t count) {
if (!_fs || !_writable) return 0;
++_fs->writes;
if (_fs->fail_write) return 0;
auto& bytes = _fs->files.at(_path);
bytes.insert(bytes.end(), buffer, buffer + count);
return count;
}
+2
View File
@@ -0,0 +1,2 @@
#pragma once
namespace mesh { struct LocalIdentity {}; }
@@ -0,0 +1,23 @@
#pragma once
#include <stddef.h>
namespace mesh { namespace ota {
struct OtaManager {
enum State { IDLE, FAILED, FETCHING, VERIFYING, COMPLETE };
State state = IDLE;
size_t serve_jobs = 0, manifest_jobs = 0;
State fetchState() const { return state; }
size_t pendingServeJobs() const { return serve_jobs; }
size_t pendingManifestJobs() const { return manifest_jobs; }
};
struct OtaContext {
OtaManager manager;
bool apply_pending = false, bootloader_apply_pending = false;
bool folder_active = false, folder_dest = false, capture_waiting = false;
bool serving = false, serve_expected = false;
bool folderCaptureWaiting() const { return capture_waiting; }
};
inline OtaContext* test_ota_context = nullptr;
inline const OtaContext* ota_context_if_active() { return test_ota_context; }
} } // namespace mesh::ota
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <FS.h>
struct stat { size_t st_size = 0; };
inline int stat(const char* absolute, struct stat* info) {
const std::string prefix = "/spiffs";
const std::string path(absolute);
if (!fs::stat_filesystem || path.compare(0, prefix.size(), prefix)) {
errno = EIO;
return -1;
}
const auto relative = path.substr(prefix.size());
if (fs::stat_filesystem->fail_stat == relative) { errno = EIO; return -1; }
const auto found = fs::stat_filesystem->files.find(relative);
if (found == fs::stat_filesystem->files.end()) { errno = ENOENT; return -1; }
info->st_size = found->second.size();
return 0;
}
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -26,7 +26,11 @@ HARNESS = r'''
struct NodePrefs { @FIELDS@ };
void markDirectRetryPrefsValid(NodePrefs*) {}
bool isValidLoRaBandwidth(float bw) { return bw==62.5f || bw==125; }
namespace mesh { struct RadioProfiles { static constexpr unsigned MaxPreamble=65535; }; }
namespace mesh {
struct RadioProfiles { static constexpr unsigned MaxPreamble=65535; };
void setUsbLoggingEnabled(bool) {}
void setUsbDebugEnabled(bool) {}
}
struct Profiles {
bool accepted=true;
uint16_t saved=48;
@@ -78,7 +82,7 @@ int main() {
CommonCLI cli;
assert(cli.saveCommonPrefs());
auto original=cli.fs.files["/com_prefs"];
const size_t preamble_offset=original.size()-3;
const size_t preamble_offset=original.size()-4;
assert(preamble_offset>=864);
assert(original[preamble_offset]==48 && original[preamble_offset+1]==0);
assert(original[preamble_offset+2]==1);
+358
View File
@@ -0,0 +1,358 @@
"""Exercise actual CommonCLI USB debug commands, images, and import commits."""
from pathlib import Path
import os
import re
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <math.h>
#include <cstring>
#include <map>
#include <string>
#include <vector>
#include <helpers/IdentityStore.h>
class TestFS : public MemoryFS {
public:
using MemoryFS::open;
File open(const char* path, uint8_t mode) {
return MemoryFS::open(path, mode == FILE_O_WRITE ? "w" : "r");
}
};
#undef FILESYSTEM
#define FILESYSTEM TestFS
#include "ContactFileTransaction.h"
#define ATOMIC_FILE_WRITER_IMPLEMENTATION
#include <helpers/AtomicFileWriter.h>
#include <helpers/PrefsSaveRouting.h>
#include <helpers/CommonPrefsRecovery.h>
#include <helpers/MQTTPrefsAtomicStore.h>
#include <helpers/CLICommandUtils.h>
#include <helpers/RepeaterRadioTiming.h>
#include <helpers/bridges/ESPNowBridgeFormat.h>
#include <helpers/radiolib/LR2021SideDetectorConfig.h>
#define MIN_LORA_TX_POWER -9
#define MAX_LORA_TX_POWER 22
#include <helpers/radiolib/RadioPowerLimits.h>
#define MESH_DEBUG_PRINTLN(...) ((void)0)
#define MESH_USB_LOGGING_AVAILABLE 1
#define DEFAULT_POWERSAVING_ENABLED 1
#define DEFAULT_CAD_ENABLED 0
#define BRIDGE_MAX_BAUD 115200
#define RX_POWERSAVING_DEFAULT_RX_US 65625UL
#define RX_POWERSAVING_DEFAULT_SLEEP_US 60000UL
#define FLOOD_RETRY_PATH_GATE_DISABLED 0xFF
#define MIN_LOCAL_ADVERT_INTERVAL 60
#define constrain(value, low, high) ((value) < (low) ? (low) : ((value) > (high) ? (high) : (value)))
@CONSTANTS@
namespace mesh {
struct RadioProfiles { static constexpr unsigned MaxPreamble = 65535; };
static bool usb_logging = true, usb_debug = false;
static bool prohibit_stale_debug = false;
static unsigned usb_updates = 0, debug_updates = 0;
void setUsbLoggingEnabled(bool enabled) {
if (enabled && prohibit_stale_debug) assert(!usb_debug);
usb_logging = enabled; ++usb_updates;
}
void setUsbDebugEnabled(bool enabled) { usb_debug = enabled; ++debug_updates; }
bool isUsbLoggingEnabled() { return usb_logging; }
bool isUsbDebugEnabled() { return usb_debug; }
bool isUsbDebugLoggingEnabled() { return usb_logging && usb_debug; }
}
struct NodePrefs {
@FIELDS@
bool dirty = false;
void clearDirty() { dirty = false; }
void markUnsaved() { dirty = true; }
};
@HELPERS@
void ensureRxPowerSavingDefaults(uint32_t*, uint32_t*) {}
void recalcRxPowerSavingFromLevel(uint8_t, uint8_t, float, uint8_t,
uint32_t*, uint32_t*) {}
@LEGACY@
@SERIALIZER@
@STORE@;
struct CommonCLI;
struct Callbacks {
CommonCLI* cli;
void savePrefs(PrefsSaveRouting::Scope scope);
void updateAdvertTimer() {}
};
struct CommonCLI {
FILESYSTEM fs;
NodePrefs prefs;
NodePrefs* _prefs = &prefs;
LegacyObserverTail _legacy_tail{};
Callbacks callbacks{this};
Callbacks* _callbacks = &callbacks;
bool _com_prefs_needs_upgrade = false;
bool _common_save_result_known = false, _common_save_succeeded = false;
bool _observer_save_result_known = false, _observer_save_succeeded = false;
uint32_t _prefs_save_failures = 0;
CommonCLI() {
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
fs.rename_replaces = true;
#endif
prefs.freq = 910; prefs.bw = 62.5f; prefs.sf = 7; prefs.cr = 5;
prefs.primary_radio_preamble = 48;
}
void savePrefs(FILESYSTEM*, PrefsSaveRouting::Scope);
void savePrefs(PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
bool trySavePrefs(PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common);
void loadPrefsInt(FILESYSTEM*, const char*);
bool recoverCommonPrefsFiles(FILESYSTEM*);
bool saveCommonPrefsImageAtomically(FILESYSTEM*);
bool saveMQTTPrefs(FILESYSTEM*) { return true; }
void set(const char* config, char* reply) {
@SET_DEBUG@
@SET_MASTER@
strcpy(reply, "unknown");
}
void get(const char* config, char* reply) {
@GET_DEBUG@
@GET_MASTER@
strcpy(reply, "unknown");
}
};
void Callbacks::savePrefs(PrefsSaveRouting::Scope scope) {
cli->savePrefs(&cli->fs, scope);
}
@METHODS@
// Execute the production dynamic schema, replacing only its generic codec.
// Registered uint8_t references emulate imported configuration values.
class ConfigSerializer {
protected:
virtual void structure() = 0;
template<typename T> void def(const char*, T&) {}
void def(const char* key, uint8_t& value) { bytes[key] = &value; }
void def(const char*, char*, size_t) {}
public:
std::map<std::string, uint8_t*> bytes;
bool import(const char* key, uint8_t value) {
structure();
if (!bytes.count(key)) return false;
*bytes[key] = value;
return true;
}
};
@DYNAMIC@;
struct Capture {
std::vector<uint8_t> bytes;
size_t write(const uint8_t* data, size_t n) {
bytes.insert(bytes.end(), data, data + n);
return n;
}
};
int main() {
CommonCLI cli;
char reply[160] = {};
assert(cli.prefs.usb_debug_enabled == 0);
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(cli._common_save_succeeded);
const auto baseline = cli.fs.files["/com_prefs"];
assert(baseline.size() == 870);
const size_t debug_offset = baseline.size() - 1;
const size_t logging_offset = baseline.size() - 7;
const size_t preamble_offset = baseline.size() - 4;
const size_t espnow_offset = baseline.size() - 2;
assert(baseline[debug_offset] == 0 && baseline[logging_offset] == 1);
assert(baseline[preamble_offset] == 48 && baseline[espnow_offset] == 1);
Capture capture;
assert(writeCommonPrefsImage(capture, &cli.prefs));
assert(capture.bytes == baseline); // Checked and ordinary writers agree.
cli.set("usb.debug on", reply);
assert(strcmp(reply, "OK - USB debug on (saved)") == 0);
assert(cli.prefs.usb_debug_enabled == 1 && mesh::isUsbDebugLoggingEnabled());
auto enabled = cli.fs.files["/com_prefs"];
assert(enabled.size() == baseline.size() && enabled[debug_offset] == 1);
assert(std::equal(baseline.begin(), baseline.begin() + debug_offset, enabled.begin()));
cli.set("usb.logging off", reply);
assert(!mesh::isUsbLoggingEnabled() && mesh::isUsbDebugEnabled());
assert(!mesh::isUsbDebugLoggingEnabled());
cli.get("usb.debug", reply);
assert(strcmp(reply, "> on") == 0); // Saved intent is visible with master OFF.
cli.get("usb.logging", reply);
assert(strcmp(reply, "> off") == 0);
// Malformed tokens and suffixes never mutate either gate or the image.
const auto unchanged = cli.fs.files["/com_prefs"];
for (const char* command : {"usb.debug", "usb.debug true", "usb.debug 1",
"usb.debug ON", "usb.debug off junk", "usb.debug off ",
"usb.debug on reboot", "usb.debug off reboot"}) {
const unsigned updates = mesh::debug_updates;
cli.set(command, reply);
assert(strcmp(reply, "Error: usage set usb.debug on|off") == 0);
assert(cli.prefs.usb_debug_enabled == 1 && mesh::isUsbDebugEnabled());
assert(mesh::debug_updates == updates);
assert(cli.fs.files["/com_prefs"] == unchanged);
}
cli.set("usb.debugger off", reply);
assert(strcmp(reply, "unknown") == 0);
cli.set("usb.debug\t \toff", reply);
assert(strcmp(reply, "OK - USB debug off (saved)") == 0);
assert(cli.prefs.usb_debug_enabled == 0 && !mesh::isUsbDebugEnabled());
const auto debug_off = cli.fs.files["/com_prefs"];
// Failed first/write/verification/publication stages cannot enable debug.
for (int fault : {0, 1, 2, 3}) {
if (fault == 0) cli.fs.fail_write = true;
if (fault == 1) cli.fs.fail_write_after = debug_offset;
if (fault == 2) cli.fs.fail_read_open = true;
if (fault == 3) cli.fs.fail_rename = 1;
const unsigned updates = mesh::debug_updates;
cli.set("usb.debug on", reply);
assert(strcmp(reply, "Error: USB debug not saved; unchanged") == 0);
assert(cli.prefs.usb_debug_enabled == 0 && !mesh::isUsbDebugEnabled());
assert(mesh::debug_updates == updates && !mesh::isUsbLoggingEnabled());
assert(cli.fs.files["/com_prefs"] == debug_off);
cli.fs.fail_write = false; cli.fs.fail_write_after = -1;
cli.fs.fail_read_open = false; cli.fs.fail_rename = 0;
}
// Imported schemas apply gates only after a successful common commit.
BridgePrefs dynamic(&cli.prefs);
assert(dynamic.import("usb_dbg", 1));
assert(!mesh::isUsbDebugEnabled());
const unsigned observer_updates = mesh::debug_updates;
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Observer);
assert(!mesh::isUsbDebugEnabled() && mesh::debug_updates == observer_updates);
assert(cli.fs.files["/com_prefs"] == debug_off);
cli.fs.fail_write = true;
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(!cli._common_save_succeeded && !mesh::isUsbDebugEnabled());
cli.fs.fail_write = false;
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(cli._common_save_succeeded && mesh::isUsbDebugEnabled());
assert(!mesh::isUsbDebugLoggingEnabled());
assert(dynamic.import("usb_log", 1));
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(mesh::isUsbDebugLoggingEnabled());
// A debug-OFF import must take effect before the master is enabled again.
mesh::setUsbLoggingEnabled(false);
assert(dynamic.import("usb_dbg", 0));
mesh::prohibit_stale_debug = true;
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
mesh::prohibit_stale_debug = false;
assert(mesh::isUsbLoggingEnabled() && !mesh::isUsbDebugEnabled());
for (uint8_t bad : {2, 255}) {
assert(dynamic.import("usb_dbg", bad));
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(cli._common_save_succeeded && !mesh::isUsbDebugEnabled());
assert(cli.prefs.usb_debug_enabled == 0 && cli.fs.files["/com_prefs"][debug_offset] == 0);
}
// Every older/torn length inherits debug OFF, even after loading debug ON.
auto image = baseline;
image[logging_offset] = 0; image[debug_offset] = 1;
for (size_t size = 0; size < image.size(); ++size) {
CommonCLI reader;
reader.prefs.usb_debug_enabled = 1;
reader.fs.files["/com_prefs"] = {image.begin(), image.begin() + size};
reader.loadPrefsInt(&reader.fs, "/com_prefs");
assert(reader.prefs.usb_debug_enabled == 0);
if (size > logging_offset) assert(reader.prefs.usb_logging_enabled == 0);
}
for (uint8_t saved : {0, 1, 2, 255}) {
image[debug_offset] = saved;
cli.fs.files["/com_prefs"] = image;
cli.prefs.usb_debug_enabled = 1;
cli.loadPrefsInt(&cli.fs, "/com_prefs");
assert(cli.prefs.usb_debug_enabled == (saved == 1 ? 1 : 0));
assert(cli.prefs.usb_logging_enabled == 0);
assert(cli.prefs.primary_radio_preamble == 48 && cli.prefs.espnow_bridge_enabled == 1);
}
cli.prefs.usb_debug_enabled = 1;
cli.loadPrefsInt(&cli.fs, "/missing");
assert(cli.prefs.usb_debug_enabled == 0);
}
'''
class CommonUsbDebugTest(unittest.TestCase):
def test_actual_common_commands_and_persistence(self):
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8')
header = (ROOT / 'src/helpers/CommonCLI.h').read_text(encoding='utf-8')
fields = header.split('class NodePrefs :', 1)[1].split('private:', 1)[0]
fields = '\n'.join(re.findall(
r'^\s*(?:float|double|char|u?int(?:8|16|32)_t)\s+[^;]+;', fields, re.M))
constants = '\n'.join(re.findall(
r'^\s*#define\s+\w+\s+(?:0x[\dA-Fa-f]+|\d+)\s*$', header, re.M))
constants += '\n' + '\n'.join(re.findall(
r'^static const size_t LEGACY_\w+\s*=[^;]+;', source, re.M))
constants += '\n' + re.search(
r'^static constexpr uint8_t DEFAULT_ADVERT_LOC_POLICY\s*=[^;]+;',
header, re.M).group()
helpers = '\n'.join(extract_braced(source, signature) for signature in (
'static bool bwMatches(', 'static bool isValidLoRaBandwidth(',
'static float defaultLoRaBandwidth(', 'static void markDirectRetryPrefsValid(',
'static void applyFloodRetryPreset(', 'static void applyDirectRetryPreset(',
'static void setDefaultDirectRetryPrefs(', 'static bool directRetryPrefsValid('))
methods = '\n'.join(extract_braced(source, signature) for signature in (
'static const char* commonPrefsSaveResultName(',
'bool CommonCLI::recoverCommonPrefsFiles(',
'bool CommonCLI::saveCommonPrefsImageAtomically(',
'void CommonCLI::savePrefs(FILESYSTEM*',
'void CommonCLI::savePrefs(PrefsSaveRouting::Scope scope)',
'bool CommonCLI::trySavePrefs(', 'void CommonCLI::loadPrefsInt('))
code = HARNESS
for key, value in {
'FIELDS': fields, 'CONSTANTS': constants, 'HELPERS': helpers,
'LEGACY': extract_braced(header, 'struct LegacyObserverTail') + ';',
'SERIALIZER': 'template<typename Writer>\n' + extract_braced(
source, 'static bool writeCommonPrefsImage('),
'STORE': extract_braced(source, 'class CommonPrefsFileStore'),
'METHODS': methods,
'DYNAMIC': extract_braced(header, 'class BridgePrefs :'),
'SET_DEBUG': extract_braced(source, 'if (strncmp(config, "usb.debug", 9)'),
'SET_MASTER': extract_braced(source, 'if (strncmp(config, "usb.logging", 11)'),
'GET_DEBUG': extract_braced(source, 'if (strcmp(config, "usb.debug") == 0)'),
'GET_MASTER': extract_braced(source, 'if (strcmp(config, "usb.logging") == 0)'),
}.items():
code = code.replace('@' + key + '@', value)
sanitizer_flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
with tempfile.TemporaryDirectory(prefix='common-usb-debug-') as directory:
work = Path(directory)
transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text()
(work / 'ContactFileTransaction.h').write_text(transaction.replace(
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'))
(work / 'test.cpp').write_text(code, encoding='utf-8')
variants = [('ENABLE_OTA', platform) for platform in (
'NRF52_PLATFORM', 'STM32_PLATFORM', 'ESP32_PLATFORM', 'RP2040_PLATFORM')]
variants.append(('ENABLE_OTA', 'ESP32_PLATFORM', 'WITH_MQTT_BRIDGE'))
variants.extend((('NRF52_PLATFORM',), ('ESP32_PLATFORM', 'WITH_MQTT_BRIDGE')))
for macros in variants:
with self.subTest(macros=macros):
exe = work / ('test.exe' if os.name == 'nt' else 'test')
built = subprocess.run([
os.environ.get('CXX', 'g++'), '-std=c++17',
*['-D' + macro + '=1' for macro in macros], *sanitizer_flags,
'-I' + str(work), '-I' + str(ROOT / 'test/fixtures/radio_profiles/mocks'),
'-I' + str(ROOT / 'src'), '-I' + str(ROOT / 'src/helpers'),
str(work / 'test.cpp'), '-o', str(exe)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stderr)
tested = subprocess.run([str(exe)], capture_output=True, text=True, timeout=10)
self.assertEqual(tested.returncode, 0, tested.stderr)
def test_outer_load_applies_both_gates(self):
source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8')
load = extract_braced(source, 'void CommonCLI::loadPrefs(FILESYSTEM*')
self.assertIn('_prefs->usb_debug_enabled = 0;', load)
self.assertIn('mesh::setUsbLoggingEnabled(_prefs->usb_logging_enabled != 0);', load)
self.assertIn('mesh::setUsbDebugEnabled(_prefs->usb_debug_enabled != 0);', load)
self.assertLess(load.index('mesh::setUsbDebugEnabled('),
load.index('mesh::setUsbLoggingEnabled('))
if __name__ == '__main__':
unittest.main()
+9 -7
View File
@@ -32,13 +32,13 @@ int main(){
assert(mesh.savePrefs());
const auto on_disk=mesh.store.fs.files["/new_prefs"];
#if defined(TBEAM_1W)
assert(on_disk.size()==239);
assert(on_disk.size()==240);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(on_disk.size()==329);
assert(on_disk.size()==330);
#else
assert(on_disk.size()==232);
assert(on_disk.size()==233);
#endif
assert(on_disk.back()==1);
assert(on_disk[on_disk.size()-2]==1&&on_disk.back()==0);
// Both choices survive a fresh instance reading the actual on-device image.
for(bool enabled : {false,true,false}){
assert(mesh.setBluetoothEnabledPreference(enabled));
@@ -49,7 +49,8 @@ int main(){
assert(prefs.ble_pin==876543&&prefs.freq==910.525f);
assert(prefs.bluetooth_stealth_mode==2&&prefs.one_key_dm_enabled==1);
assert(prefs.wifi_enabled==0&&prefs.usb_logging_enabled==1);
assert(reboot.fs.files["/new_prefs"].back()==enabled);
const auto& saved=reboot.fs.files["/new_prefs"];
assert(saved[saved.size()-2]==enabled&&saved.back()==0);
}
const auto off_disk=mesh.store.fs.files["/new_prefs"];
// The public setter must roll live state back when the atomic save fails.
@@ -69,7 +70,7 @@ int main(){
assert(mesh.isBluetoothEnabledPreference());
mesh.store.fs.fail_write=false;
// Legacy images cannot accidentally inherit an off value from the caller.
for(unsigned size : {84u,156u,215u,226u,unsigned(on_disk.size()-1)}){
for(unsigned size : {84u,156u,215u,226u,unsigned(on_disk.size()-2)}){
DataStore legacy;legacy.fs.files["/new_prefs"]=on_disk;
legacy.fs.files["/new_prefs"].resize(size);
CompanionNodePrefs prefs;prefs.bluetooth_enabled=0;
@@ -80,7 +81,8 @@ int main(){
// Invalid new encoding rejects the transaction rather than partially loading.
for(uint8_t corrupt : {2,255}){
DataStore damaged;damaged.fs.files["/new_prefs"]=off_disk;
damaged.fs.files["/new_prefs"].back()=corrupt;
auto& damaged_disk=damaged.fs.files["/new_prefs"];
damaged_disk[damaged_disk.size()-2]=corrupt;
CompanionNodePrefs prefs;prefs.bluetooth_enabled=1;
strcpy(prefs.node_name,"unchanged");double lat=1,lon=2;
assert(!damaged.loadPrefsInt("/new_prefs",prefs,lat,lon));
+203
View File
@@ -0,0 +1,203 @@
"""Execute the public-key CLI with production formatting and real identity data."""
from itertools import product
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <array>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
#include <Identity.h>
namespace mesh {
@HEX_CHARS@
@TO_HEX@
@IDENTITY_METHODS@
}
class IdentityInput : public Stream {
public:
std::vector<uint8_t> bytes;
size_t offset=0;
int available() override{return int(bytes.size()-offset);}
int read() override{return offset<bytes.size()?bytes[offset++]:-1;}
};
struct MyMesh {
mesh::LocalIdentity self_id;
unsigned fallback_calls=0;
bool handleLocalControlCommand(const char*,char*,size_t);
};
@COMMAND@
std::array<uint8_t,PRV_KEY_SIZE+PUB_KEY_SIZE> snapshot(const MyMesh& node){
std::array<uint8_t,PRV_KEY_SIZE+PUB_KEY_SIZE> bytes;
assert(node.self_id.writeTo(bytes.data(),bytes.size())==bytes.size());
return bytes;
}
int main(){
static_assert(PUB_KEY_SIZE==32&&PRV_KEY_SIZE==64,"Exercise the entire identity");
const uint8_t public_key[PUB_KEY_SIZE]={
0x00,0xff,0x01,0x80,0x0a,0xaf,0x10,0x7f,
0x55,0xaa,0xfe,0xef,0x03,0x20,0x9c,0xcd,
0x04,0x30,0xb2,0xd3,0x05,0x40,0x56,0xe4,
0x06,0x50,0x78,0xf5,0x07,0x60,0x9a,0xc6
};
const std::string expected=
"> 00FF01800AAF107F55AAFEEF03209CCD0430B2D3054056E4065078F507609AC6";
assert(expected.size()==2+PUB_KEY_SIZE*2);
IdentityInput input;
input.bytes.insert(input.bytes.end(),public_key,public_key+sizeof(public_key));
for(unsigned i=0;i<PRV_KEY_SIZE;++i)input.bytes.push_back(uint8_t(0xe7^i));
MyMesh node;assert(node.self_id.readFrom(input));
const auto original=snapshot(node);
assert(!memcmp(node.self_id.pub_key,public_key,sizeof(public_key)));
for(unsigned i=0;i<PRV_KEY_SIZE;++i)assert(original[i]==uint8_t(0xe7^i));
// Every capacity around the 66-byte reply boundary is exercised with exact
// prefix expectations and both leading/trailing canaries under sanitizers.
std::vector<size_t> capacities;
for(size_t size=1;size<=80;++size)capacities.push_back(size);
for(size_t size : {size_t(128),size_t(160),size_t(176),size_t(256)})capacities.push_back(size);
for(size_t size : capacities){
std::vector<uint8_t> guarded(size+16,0xa7);
char* reply=reinterpret_cast<char*>(guarded.data()+8);
const unsigned fallback=node.fallback_calls;
assert(node.handleLocalControlCommand("get public.key",reply,size));
const size_t written=size-1<expected.size()?size-1:expected.size();
assert(strlen(reply)==written&&!memcmp(reply,expected.data(),written));
assert(reply[written]==0&&node.fallback_calls==fallback);
for(size_t i=0;i<8;++i)assert(guarded[i]==0xa7);
for(size_t i=8+size;i<guarded.size();++i)assert(guarded[i]==0xa7);
for(size_t i=written+1;i<size;++i)assert(uint8_t(reply[i])==0xa7);
assert(snapshot(node)==original);
}
// Invalid caller pointers and zero capacity never read commands or change
// output, identity, or any later command-handler state.
char guarded[160];memset(guarded,'X',sizeof(guarded));
const unsigned before_invalid=node.fallback_calls;
assert(!node.handleLocalControlCommand(nullptr,guarded,sizeof(guarded)));
assert(!node.handleLocalControlCommand("get public.key",nullptr,sizeof(guarded)));
assert(!node.handleLocalControlCommand("get public.key",guarded,0));
assert(!node.handleLocalControlCommand(nullptr,nullptr,0));
for(char byte : guarded)assert(byte=='X');
assert(node.fallback_calls==before_invalid&&snapshot(node)==original);
// A const input is accepted without modification, as is the established
// leading-space normalization. Other whitespace is not an implicit alias.
char command[]=" get public.key";
const std::string command_before=command;
assert(node.handleLocalControlCommand(command,guarded,sizeof(guarded)));
assert(guarded==expected&&command_before==command&&snapshot(node)==original);
auto reject=[&](const std::string& command){
memset(guarded,'X',sizeof(guarded));
const unsigned before=node.fallback_calls;
assert(!node.handleLocalControlCommand(command.c_str(),guarded,sizeof(guarded)));
assert(node.fallback_calls==before+1);
for(char byte : guarded)assert(byte=='X');
assert(snapshot(node)==original);
};
for(const char* command : {"", " ", "get public", "get public.keyx",
"get public.key extra", "get public.key ", "get public.key\t",
"get public.key\r", "get public.key\n", "get\tpublic.key",
"\tget public.key", "GET public.key", "get Public.key", "get public.KEY",
"get pub.key", "get publickey", "get public_key", "get private.key",
"get prv.key", "set public.key", "set public.key 00", "set prv.key 00",
"erase public.key", "public.key"})reject(command);
const std::string exact="get public.key";
for(size_t length=0;length<exact.size();++length)reject(exact.substr(0,length));
for(size_t offset=0;offset<exact.size();++offset){
std::string mutated=exact;mutated[offset]='#';reject(mutated);
}
// Read the live identity, not a hardcoded value or a cached response. The
// public query must never expose the different, nonzero private-key bytes.
for(unsigned i=0;i<PUB_KEY_SIZE;++i)node.self_id.pub_key[i]=uint8_t(0xff-i);
const auto changed=snapshot(node);
assert(!memcmp(changed.data(),original.data(),PRV_KEY_SIZE));
assert(node.handleLocalControlCommand("get public.key",guarded,sizeof(guarded)));
assert(!strcmp(guarded,
"> FFFEFDFCFBFAF9F8F7F6F5F4F3F2F1F0EFEEEDECEBEAE9E8E7E6E5E4E3E2E1E0"));
assert(snapshot(node)==changed);
}
'''
class CompanionIdentityCliTests(unittest.TestCase):
def test_production_query_bounds_exact_matching_identity_and_feature_gates(self):
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
local = method(source, 'bool MyMesh::handleLocalControlCommand(')
# Keep the production method signature, null/capacity guard, leading
# normalization and identity branch intact. The unrelated later
# handlers are replaced only by an observable fallthrough sentinel.
dispatch = local.index(' if (handleTxRoutingCommand(')
command = local[:dispatch] + ' ++fallback_calls;\n return false;\n}'
utils = (ROOT / 'src/Utils.cpp').read_text()
hex_chars = next(line for line in utils.splitlines()
if line.startswith('static const char hex_chars[]'))
identity = (ROOT / 'src/Identity.cpp').read_text()
identity_methods = '\n'.join(method(identity, signature) for signature in (
'Identity::Identity()', 'LocalIdentity::LocalIdentity()',
'bool LocalIdentity::readFrom(Stream& s)',
'size_t LocalIdentity::writeTo(uint8_t* dest, size_t max_len) const'))
harness = HARNESS.replace('@COMMAND@', command)
harness = harness.replace('@HEX_CHARS@', hex_chars)
harness = harness.replace('@TO_HEX@', method(utils, 'void Utils::toHex('))
harness = harness.replace('@IDENTITY_METHODS@', identity_methods)
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
cpp = work / 'identity.cpp'
cpp.write_text(harness)
binary = work / 'identity'
for debug, packets, private_export in product((0, 1), repeat=3):
with self.subTest(debug=debug, packets=packets, private_export=private_export):
flags = ['ARDUINO=1', 'MESH_DEBUG=' + str(debug),
'MESH_PACKET_LOGGING=' + str(packets),
'ENABLE_PRIVATE_KEY_EXPORT=' + str(private_export),
'COMPANION_FEATURE_TEXT_TERMINAL=' + str(private_export),
'COMPANION_FEATURE_OTA_CLI=' + str(packets)]
if not debug and not packets:
flags.append('MESH_USB_LOGGING_DISABLED=1')
if debug and packets:
flags += ['COMPANION_RADIO_FULL=1', 'WITH_MQTT_BRIDGE=1']
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie', *['-D' + flag for flag in flags],
'-I', str(ROOT / 'src'), '-I', str(ROOT / 'test/mocks'),
str(cpp), '-o', str(binary)], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
def test_terminal_binary_and_help_route_to_the_shared_readonly_query(self):
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
terminal = method(source, 'void MyMesh::handleTerminalCommand(')
self.assertIn('handleLocalControlCommand(command, local_reply, sizeof(local_reply))', terminal)
self.assertIn('terminalOutput().printf(" %s\\r\\n", local_reply);', terminal)
self.assertIn('terminalOutput().print(" get public.key\\r\\n");', terminal)
shared = method(source, 'bool MyMesh::handleCommand(')
self.assertIn('if (handleLocalControlCommand(command, reply, reply_capacity)) return true;', shared)
self.assertIn('return handleCommand(command, 0, reply);',
(ROOT / 'examples/companion_radio/MyMesh.h').read_text())
start = source.index('} else if (mesh::companion::isRunCliFrame(')
end = source.index('} else if (cmd_frame[0] == CMD_SEND_TXT_MSG', start)
framed = source[start:end]
self.assertIn('handled = handleCommand(text, 0, reply_buf);', framed)
self.assertIn('out_frame[0] = RESP_CODE_CLI_REPLY;', framed)
self.assertIn('_serial->writeFrame(out_frame, 1 + rlen);', framed)
local = method(source, 'bool MyMesh::handleLocalControlCommand(')
branch = local[:local.index(' if (handleTxRoutingCommand(')]
self.assertIn('strcmp(command, "get public.key") == 0', branch)
self.assertNotIn('#if', branch) # Not gated by private export or logging.
self.assertNotIn('prv_key', branch)
self.assertNotIn('save', branch)
if __name__ == '__main__':
unittest.main()
@@ -194,7 +194,7 @@ int main(int argc,char** argv){
} else if(scenario==7){
// Pre-consent image: all earlier settings survive with default-off consent.
DataStore legacy;legacy.fs.files["/new_prefs"]=disk;
legacy.fs.files["/new_prefs"].resize(disk.size()-2);
legacy.fs.files["/new_prefs"].resize(disk.size()-3);
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.flood_retry_advert_enabled==0);
+50 -11
View File
@@ -50,10 +50,11 @@ struct Prefs {
struct Sensors {
double node_lat=10,node_lon=20;
std::string gps="0",interval="60",other="old";
bool accepts=true;
bool accepts=true,gps_available=true;
bool powersaving_enabled=false;
void setPowerSavingEnabled(bool enabled) { powersaving_enabled=enabled; }
const char* getSettingByKey(const char* key) {
if(!strcmp(key,"gps") && !gps_available)return nullptr;
return !strcmp(key,"gps") ? gps.c_str() : interval.c_str();
}
bool setSettingValue(const char* key,const char* value) {
@@ -114,17 +115,24 @@ struct MyMesh {
void writeOKFrame() { replies.push_back(0); }
void writeErrFrame(uint8_t error) { replies.push_back(error); }
void updateGpsTelemetryPolicy() { ++gps_policy_updates; }
void applyGpsPrefs() {
sensors.setSettingValue("gps",_prefs.gps_enabled?"1":"0");
char s[16];snprintf(s,sizeof(s),"%lu",(unsigned long)_prefs.gps_interval);
sensors.setSettingValue("gps_interval",s);
}
#if ENV_INCLUDE_GPS == 1
bool setGpsEnabled(bool);
@APPLY_GPS_PREFS@
#endif
MyMesh& terminalOutput() { return *this; }
void print(const char* value) { output+=value; }
void frame(size_t len) { @FRAMES@ }
void web(const char* key,const char* value,char* reply) { @WEB@ }
void terminal(const char* config) { @TERMINAL@ }
bool gps(const char* gps_value,char* reply) { const size_t reply_size=160;@GPS@;return false; }
bool gps(const char* gps_value,char* reply) {
#if ENV_INCLUDE_GPS == 1
const size_t reply_size=160;
@GPS@
#else
(void)gps_value;(void)reply;
#endif
return false;
}
bool hasOutbound() const { return false; }
bool millisHasNowPassed(uint32_t) const { return true; }
uint32_t futureMillis(uint32_t delay) const { return delay+100; }
@@ -155,6 +163,9 @@ struct MyMesh {
}
};
@METHODS@
#if ENV_INCLUDE_GPS == 1
@GPS_METHOD@
#endif
static void u32(std::vector<uint8_t>& bytes,uint32_t value) {
for(unsigned i=0;i<4;++i) bytes.push_back(uint8_t(value>>(8*i)));
}
@@ -218,12 +229,38 @@ int main() {
m.expectOriginal();
}
#if ENV_INCLUDE_GPS == 1
for(bool save_ok:{false,true}) {
for(bool initial:{false,true})for(bool save_ok:{false,true}) {
MyMesh m;char reply[160]={};m.storage_accepts=save_ok;
assert(m.gps("on",reply));++checks;
m._prefs.gps_enabled=m.durable.gps_enabled=initial;
sensors.gps=initial?"1":"0";
assert(m.gps(initial?"off":"on",reply));++checks;
const bool expected=save_ok?!initial:initial;
assert(error(reply)!=save_ok && m.saves==1);
assert(sensors.gps==(save_ok?"1":"0"));
if(!save_ok)m.laterSaveCannotResurrectFailure();
assert(sensors.gps==(expected?"1":"0") && sensors.interval=="60");
assert(m._prefs.gps_enabled==expected && m.durable.gps_enabled==expected);
m.storage_accepts=true;assert(m.saveAdvertName("later"));
assert(m.durable.gps_enabled==expected);
}
for(bool initial:{false,true})for(bool available:{false,true}) {
MyMesh m;char reply[160]={};
m._prefs.gps_enabled=m.durable.gps_enabled=initial;
sensors.gps=initial?"1":"0";
sensors.gps_available=available;sensors.accepts=false;
assert(m.gps(initial?"off":"on",reply));++checks;
assert(error(reply) && m.saves==0);
assert(sensors.gps==(initial?"1":"0"));
assert(m._prefs.gps_enabled==initial && m.durable.gps_enabled==initial);
}
for(const char* value:{"", "1", "ON", "on extra"}) {
MyMesh m;char reply[160]={};assert(m.gps(value,reply));++checks;
assert(error(reply) && m.saves==0 && sensors.gps=="0");m.expectOriginal();
}
#else
{
MyMesh m;char reply[160]="unchanged";
assert(!m.gps("on",reply) && !m.gps("off",reply));++checks;
assert(!strcmp(reply,"unchanged") && m.saves==0 && sensors.gps=="0");
m.expectOriginal();
}
#endif
// Checked RXPS applies roll back hardware too, and keep retrying a busy
@@ -352,6 +389,8 @@ class CompanionPrefsTransactionTests(unittest.TestCase):
'static uint32_t nextRadioApplyRetryDelay(',
)),
'@SAVE_PREFERENCE@': extract_braced(header, 'template <typename T> bool savePreference('),
'@APPLY_GPS_PREFS@': extract_braced(header, 'void applyGpsPrefs('),
'@GPS_METHOD@': extract_braced(text, 'bool MyMesh::setGpsEnabled('),
'@METHODS@': '\n'.join(extract_braced(text,sig) for sig in (
'bool MyMesh::saveAdvertName(', 'bool MyMesh::saveAdvertLocation(',
'bool MyMesh::applyAndSaveRxPowerSaving(',
+452
View File
@@ -0,0 +1,452 @@
"""Execute Companion debug persistence/CLI and bounded packet logging paths."""
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_companion_preferences_transaction import HARNESS as STORE_HARNESS
from test_companion_preferences_transaction import esp_recovery_helpers
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
PREFERENCE_HARNESS = r'''
struct ConfigText : Stream {
std::string text;size_t offset=0;
int available() override{return int(text.size()-offset);}
int read() override{return offset<text.size()?uint8_t(text[offset++]):-1;}
int peek() override{return offset<text.size()?uint8_t(text[offset]):-1;}
size_t write(uint8_t value) override{text.push_back(char(value));return 1;}
size_t write(const uint8_t* value,size_t size) override{
text.append(reinterpret_cast<const char*>(value),size);return size;
}
size_t print(unsigned int value,int base) override{
assert(base==10);const auto number=std::to_string(value);
return write(reinterpret_cast<const uint8_t*>(number.data()),number.size());
}
};
namespace mesh {
bool logging_enabled=true, debug_enabled=false;
unsigned debug_applies=0;
bool isUsbLoggingEnabled(){return logging_enabled;}
bool isUsbDebugEnabled(){return debug_enabled;}
void setUsbDebugEnabled(bool enabled){debug_enabled=enabled;++debug_applies;}
}
struct {double node_lat=47.1,node_lon=-122.2;} sensors;
struct MyMesh {
CompanionNodePrefs _prefs;
DataStore store;
DataStore* _store=&store;
@SAVE_PREFS@
@SAVE_PREFERENCE@
bool command(const char* command,char* reply,size_t reply_size){
@DEBUG_COMMANDS@
return false;
}
};
int main(){
MyMesh node;
assert(node._prefs.usb_debug_enabled==0);
strcpy(node._prefs.node_name,"durable node");node._prefs.freq=910.525f;
node._prefs.ble_pin=876543;node._prefs.bluetooth_enabled=0;
node._prefs.one_key_dm_enabled=1;node._prefs.usb_logging_enabled=1;
assert(node.savePrefs());
const auto original=node.store.fs.files["/new_prefs"];
#if defined(TBEAM_1W)
assert(original.size()==240);
#elif defined(RP2040_PLATFORM) && defined(ENABLE_WIFI_INTERFACE)
assert(original.size()==330);
#else
assert(original.size()==233);
#endif
assert(original.back()==0&&original[original.size()-2]==0);
assert(original[original.size()-3]==1&&original[158]==1);
char reply[160]={};
for(const char* malformed : {"set usb.debug","set usb.debug ",
"set usb.debug 1","set usb.debug ON","set usb.debug on ",
"set usb.debug on reboot","set usb.debug off extra", "set usb.debug off\n"}){
const auto before=node.store.fs.files["/new_prefs"];
const unsigned applied=mesh::debug_applies;
assert(node.command(malformed,reply,sizeof(reply)));
assert(!strcmp(reply,"Error: use set usb.debug <on|off>"));
assert(node._prefs.usb_debug_enabled==0&&!mesh::debug_enabled);
assert(mesh::debug_applies==applied&&node.store.fs.files["/new_prefs"]==before);
}
for(const char* unrelated : {"set usb.debugger on","get usb.debugx","get usb.debug on"})
assert(!node.command(unrelated,reply,sizeof(reply)));
assert(node.command("set usb.debug\t on",reply,sizeof(reply)));
assert(!strncmp(reply,"OK - USB debug on (saved)",24));
assert(node._prefs.usb_debug_enabled==1&&mesh::debug_enabled);
const auto enabled_disk=node.store.fs.files["/new_prefs"];
assert(enabled_disk.back()==1);
for(size_t i=0;i+1<original.size();++i)assert(enabled_disk[i]==original[i]);
assert(node.command("get usb.debug",reply,sizeof(reply)));
#if MESH_DEBUG
assert(!strcmp(reply,"usb.debug on"));
#else
assert(!strcmp(reply,"usb.debug on (core debug not compiled)"));
#endif
mesh::logging_enabled=false;
assert(node.command("get usb.debug",reply,sizeof(reply)));
#if MESH_DEBUG
assert(!strcmp(reply,"usb.debug on (USB logging off)"));
#endif
// Faults at early and newly appended bytes both preserve the committed image
// and requested runtime state. A subsequent successful save applies once.
for(int fail_after : {17,int(enabled_disk.size()-1)}){
node.store.fs.fail_write_after=fail_after;
const unsigned applied=mesh::debug_applies;
assert(node.command("set usb.debug off",reply,sizeof(reply)));
assert(!strcmp(reply,"Error: USB debug setting could not be saved"));
assert(node._prefs.usb_debug_enabled==1&&mesh::debug_enabled);
assert(mesh::debug_applies==applied);
assert(node.store.fs.files["/new_prefs"]==enabled_disk);
}
node.store.fs.fail_write_after=-1;
assert(node.command("set usb.debug off",reply,sizeof(reply)));
assert(node._prefs.usb_debug_enabled==0&&!mesh::debug_enabled);
for(uint8_t enabled : {0,1}){
node._prefs.usb_debug_enabled=enabled;assert(node.savePrefs());
assert(mesh::debug_enabled==(enabled!=0));
DataStore reboot;reboot.fs=node.store.fs;
CompanionNodePrefs loaded;loaded.usb_debug_enabled=1-enabled;
double lat=0,lon=0;assert(reboot.loadPrefs(loaded,lat,lon));
assert(loaded.usb_debug_enabled==enabled&&loaded.bluetooth_enabled==0);
assert(loaded.one_key_dm_enabled==1&&loaded.usb_logging_enabled==1);
assert(loaded.ble_pin==876543&&loaded.freq==910.525f);
}
// Existing complete tails retain their established offsets and always start
// quiet, even if the object was already using verbose runtime diagnostics.
for(unsigned size : {84u,156u,215u,226u,unsigned(original.size()-1)}){
DataStore legacy;legacy.fs.files["/new_prefs"]=enabled_disk;
legacy.fs.files["/new_prefs"].resize(size);
CompanionNodePrefs loaded;loaded.usb_debug_enabled=1;
double lat=0,lon=0;assert(legacy.loadPrefs(loaded,lat,lon));
assert(loaded.usb_debug_enabled==0&&loaded.ble_pin==876543);
assert(legacy.fs.files["/new_prefs"].size()==size);
}
// Normalize the new boolean only after the entire persisted image validates.
for(uint8_t byte : {2,255}){
DataStore corrupt;corrupt.fs.files["/new_prefs"]=enabled_disk;
corrupt.fs.files["/new_prefs"].back()=byte;
CompanionNodePrefs loaded;double lat=0,lon=0;
assert(corrupt.loadPrefsInt("/new_prefs",loaded,lat,lon));
assert(loaded.usb_debug_enabled==0);
}
DataStore extra;extra.fs.files["/new_prefs"]=enabled_disk;
extra.fs.files["/new_prefs"].push_back(0);
CompanionNodePrefs unchanged;unchanged.usb_debug_enabled=0;
double lat=1,lon=2;assert(!extra.loadPrefsInt("/new_prefs",unchanged,lat,lon));
assert(unchanged.usb_debug_enabled==0&&lat==1&&lon==2);
// Numeric dynamic configuration uses the actual preference, not a shadow
// string in the custom dictionary, and applies through the same save path.
char dynamic[8]={};
assert(node._prefs.getCustom()->setByKey("usb_debug","1"));
assert(node._prefs.usb_debug_enabled==1&&node._prefs.isDirty());
assert(node._prefs.getCustom()->getByKey("usb_debug",dynamic,sizeof(dynamic)));
assert(!strcmp(dynamic,"1"));
assert(node.savePrefs()&&mesh::debug_enabled&&!node._prefs.isDirty());
assert(node._prefs.getCustom()->setByKey("usb_debug","0"));
assert(node.savePrefs()&&!mesh::debug_enabled);
// The radio serializer exports the preference; custom JSON imports delegate
// to the same field rather than creating an unrelated string preference.
ConfigText imported;imported.text="{usb_debug:1}";
assert(node._prefs.getCustom()->getByKey("usb_debug",dynamic,sizeof(dynamic)));
auto* serializer=static_cast<DynamicConfigSerializer*>(node._prefs.getCustom());
assert(serializer->loadSerial(imported)&&node._prefs.usb_debug_enabled==1);
assert(node.savePrefs()&&mesh::debug_enabled);
ConfigText exported;assert(node._prefs.getRadioPrefs()->saveSerial(exported));
assert(exported.text.find("usb_debug:1")!=std::string::npos);
// Only an explicit 1 enables verbosity, including when a dynamic schema has
// assigned an out-of-range byte. Failed commits still leave runtime alone.
node._prefs.usb_debug_enabled=255;assert(node.savePrefs());
assert(node._prefs.usb_debug_enabled==0&&!mesh::debug_enabled);
node._prefs.usb_debug_enabled=255;node.store.fs.fail_write=true;
assert(!node.savePrefs()&&node._prefs.usb_debug_enabled==255&&!mesh::debug_enabled);
node.store.fs.fail_write=false;node._prefs.usb_debug_enabled=0;
// Bounded replies do not write beyond their supplied capacity.
char small[9];memset(small,'X',sizeof(small));
assert(node.command("get usb.debug",small,8));assert(small[7]==0&&small[8]=='X');
}
'''
PACKET_HARNESS = r'''
#include <cassert>
#include <cstring>
#include <string>
#include <vector>
#include <ctime>
#include <helpers/SerialPacketLog.h>
#include <helpers/BaseSerialInterface.h>
std::vector<std::string> events;
struct Port : Stream {
std::string output;
bool stalled=false;
int availableForWrite() override{return stalled?0:4096;}
size_t write(const uint8_t* bytes,size_t size) override{
events.push_back("ascii");output.append(reinterpret_cast<const char*>(bytes),size);return size;
}
} log_port;
namespace mesh {
bool enabled=true,debug_enabled=false;
bool isUsbLoggingEnabled(){return enabled;}
Port& usbLoggingPort(){return log_port;}
}
struct Transport {
bool connected=true;
std::vector<std::vector<uint8_t>> frames;
bool isConnected() const{return connected;}
size_t writeFrame(const uint8_t* bytes,size_t size){
events.push_back("binary");frames.emplace_back(bytes,bytes+size);return size;
}
};
struct Bridge {
bool running=true;
std::vector<std::vector<uint8_t>> raw;
float snr=0,rssi=0;
bool isRunning() const{return running;}
void storeRawRadioData(const uint8_t* bytes,int len,float s,float r){
events.push_back("mqtt");raw.emplace_back(bytes,bytes+len);snr=s;rssi=r;
}
};
@PUSH_CODE@
struct Clock {
uint32_t timestamp=1791030896;
uint32_t getCurrentTime() const{return timestamp;}
};
class DateTime {
std::tm value;
public:
explicit DateTime(uint32_t timestamp){const std::time_t time=timestamp;value=*std::gmtime(&time);}
unsigned hour() const{return value.tm_hour;}
unsigned minute() const{return value.tm_min;}
unsigned second() const{return value.tm_sec;}
unsigned day() const{return value.tm_mday;}
unsigned month() const{return value.tm_mon+1;}
unsigned year() const{return value.tm_year+1900;}
};
struct MyMesh {
Transport transport;Transport* _serial=&transport;
Bridge bridge;Bridge* _mqtt_bridge=&bridge;
uint8_t out_frame[MAX_FRAME_SIZE]={};
Clock clock;Clock* getRTCClock(){return &clock;}
#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT
const char* getLogDateTime();
#else
const char* getLogDateTime(){return "";}
#endif
void logRxRaw(float,float,const uint8_t*,int);
};
@LOG_TIMESTAMP@
@LOG_RX_RAW@
void reset(){
events.clear();log_port.output.clear();log_port.stalled=false;
mesh::serialLogDroppedCount()=0;mesh::serialLogPortSeen()=false;
mesh::enabled=true;mesh::debug_enabled=false;g_mock_millis=0;
}
int main(){
const uint8_t raw[]={0x00,0xab,0xff};
MyMesh node;reset();node.logRxRaw(2.5f,-92.0f,raw,sizeof(raw));
#if MESH_PACKET_LOGGING
#if MESH_PACKET_LOGGING_COMPACT
assert(log_port.output=="R00ABFF\n");
#else
assert(log_port.output=="12:34:56 - 3/10/2026 U RAW: 00ABFF\r\n");
// A second invocation uses the current RTC and is the formatter dispatched
// by the shared RX/TX summary path as well as this RAW path.
assert(!strcmp(node.getLogDateTime(),"12:34:56 - 3/10/2026 U"));
node.clock.timestamp+=1;
assert(!strcmp(node.getLogDateTime(),"12:34:57 - 3/10/2026 U"));
node.clock.timestamp-=1;
#endif
assert(events.front()=="ascii"); // RAW precedes the unchanged downstream sinks.
#else
assert(log_port.output.empty());
#endif
assert(node.transport.frames.size()==1);
const std::vector<uint8_t> frame={PUSH_CODE_LOG_RX_DATA,10,uint8_t(-92),0x00,0xab,0xff};
assert(node.transport.frames[0]==frame);
#if defined(WITH_MQTT_BRIDGE) && defined(ESP32_PLATFORM) && defined(WIFI_SSID)
assert(node.bridge.raw.size()==1&&node.bridge.raw[0]==std::vector<uint8_t>(raw,raw+sizeof(raw)));
assert(node.bridge.snr==2.5f&&node.bridge.rssi==-92.0f);
assert(events[events.size()-2]=="mqtt");
#endif
reset();mesh::enabled=false;node.logRxRaw(2.5f,-92.0f,raw,sizeof(raw));
assert(log_port.output.empty()&&node.transport.frames.size()==2);
reset();node.transport.connected=false;node.logRxRaw(2.5f,-92.0f,raw,sizeof(raw));
assert(node.transport.frames.size()==2); // Disconnected binary session receives no push.
#if MESH_PACKET_LOGGING
assert(!log_port.output.empty()); // Independent diagnostics port still receives RAW.
#endif
reset();uint8_t maximum[255];memset(maximum,0xa5,sizeof(maximum));
node.logRxRaw(0,-100,maximum,sizeof(maximum));
assert(node.transport.frames.size()==2); // Never overflow the 176-byte frame buffer.
#if MESH_PACKET_LOGGING
assert(log_port.output.size()<=SERIAL_LOG_LINE_MAX);
std::string expected_hex;for(unsigned i=0;i<255;++i)expected_hex+="A5";
#if MESH_PACKET_LOGGING_COMPACT
assert(log_port.output=="R"+expected_hex+"\n");
#else
assert(log_port.output=="12:34:56 - 3/10/2026 U RAW: "+expected_hex+"\r\n");
#endif
reset();log_port.stalled=true;node.transport.connected=true;
const uint32_t before=millis();node.logRxRaw(2.5f,-92.0f,raw,sizeof(raw));
assert(log_port.output.empty()&&millis()==before);
assert(node.transport.frames.size()==3); // A stalled host cannot block other sinks.
#endif
}
'''
class CompanionUsbLoggingTests(unittest.TestCase):
def compile_and_run(self, work, text, flags, extra=()):
source = work / 'test.cpp'
source.write_text(text)
binary = work / 'test'
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie', *['-D' + flag for flag in flags],
'-include', str(work / 'platform_shim.h'),
'-I', str(work), '-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'),
'-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'),
'-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), str(source),
*[str(ROOT / 'src/helpers' / name) for name in extra],
'-o', str(binary)], capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
def prepare(self, work):
(work / 'Utils.h').write_text('''#pragma once
#include <Arduino.h>
#include <cassert>
namespace mesh { struct Utils {
static void printHex(Stream&,const uint8_t*,size_t){assert(false);}
static void fromHex(uint8_t*,size_t,const char*){assert(false);}
}; }
''')
(work / 'platform_shim.h').write_text('''#include <cstdlib>
#include <cstdio>
inline char* utoa(unsigned int value,char* output,int base){
if(base!=10)abort();sprintf(output,"%u",value);return output;
}
''')
transaction = (ROOT / 'src/helpers/ContactFileTransaction.h').read_text()
(work / 'ContactFileTransaction.h').write_text(transaction.replace(
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'))
def test_real_preference_io_cli_and_saved_runtime_gate(self):
store = (ROOT / 'examples/companion_radio/DataStore.cpp').read_text()
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
header = (ROOT / 'examples/companion_radio/MyMesh.h').read_text()
store_methods = esp_recovery_helpers(store) + '\n'.join(method(store, signature)
for signature in ('bool DataStore::loadPrefs(',
'bool DataStore::loadPrefsInt(', 'bool DataStore::savePrefs('))
prefix = STORE_HARNESS.split('int main(')[0].replace('@METHODS@', store_methods)
start = source.index(' if (strcmp(command, "get usb.debug") == 0)')
end = source.index(' if (strcmp(command, "get usb.logging") == 0)', start)
harness = PREFERENCE_HARNESS.replace('@DEBUG_COMMANDS@', source[start:end])
harness = harness.replace('@SAVE_PREFS@', method(header, ' bool savePrefs()'))
harness = harness.replace('@SAVE_PREFERENCE@', method(
header, 'template <typename T> bool savePreference('))
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
self.prepare(work)
for platform in ('NRF52_PLATFORM', 'STM32_PLATFORM', 'ESP32_PLATFORM',
'RP2040_PLATFORM', 'ESP32_PLATFORM,TBEAM_1W',
'RP2040_PLATFORM,ENABLE_WIFI_INTERFACE'):
for debug in (0, 1):
with self.subTest(platform=platform, debug=debug):
self.compile_and_run(work, prefix + harness,
[flag + '=1' for flag in platform.split(',')] +
['MESH_USB_LOGGING_AVAILABLE=1', 'MESH_DEBUG=' + str(debug)],
('ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp',
'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp'))
def test_real_raw_emitter_master_gate_compact_and_other_transports(self):
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
code = next(line for line in source.splitlines()
if line.startswith('#define PUSH_CODE_LOG_RX_DATA'))
harness = PACKET_HARNESS.replace('@PUSH_CODE@', code)
harness = harness.replace('@LOG_TIMESTAMP@',
'#if MESH_PACKET_LOGGING && !MESH_PACKET_LOGGING_COMPACT\n' +
method(source, 'const char* MyMesh::getLogDateTime()') + '\n#endif\n')
harness = harness.replace('@LOG_RX_RAW@', method(source, 'void MyMesh::logRxRaw('))
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
self.prepare(work)
for packets, compact, mqtt in ((0, 0, 0), (0, 0, 1),
(1, 0, 0), (1, 0, 1), (1, 1, 0), (1, 1, 1)):
with self.subTest(packets=packets, compact=compact, mqtt=mqtt):
flags = ['MESH_PACKET_LOGGING=' + str(packets),
'MESH_PACKET_LOGGING_COMPACT=' + str(compact)]
if mqtt:
flags += ['WITH_MQTT_BRIDGE=1', 'ESP32_PLATFORM=1', 'WIFI_SSID="test"']
self.compile_and_run(work, harness, flags)
def test_begin_defaults_clamps_and_applies_debug_before_packet_master(self):
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
self.assertIn('_prefs.usb_debug_enabled = 0;', source)
self.assertIn('_prefs.usb_debug_enabled = _prefs.usb_debug_enabled == 1 ? 1 : 0;', source)
apply = source.index('mesh::setUsbDebugEnabled(_prefs.usb_debug_enabled != 0);')
self.assertLess(apply, source.index('mesh::setUsbLoggingEnabled(usb_logging_enabled);'))
header = (ROOT / 'examples/companion_radio/MyMesh.h').read_text()
self.assertIn('const char* getLogDateTime() override;', header)
def test_actual_help_chunks_keep_controls_and_bounded_native_writes(self):
source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text()
start = source.index(' } else if (strcmp(command, "help") == 0) {')
start = source.index('{', start) + 1
end = source.index(' } else if (handleCommand(command, 0, local_reply))', start)
harness = r'''
#include <cassert>
#include <cstring>
#include <string>
struct Port {
std::string output;
void print(const char* text){assert(strlen(text)<=256);output+=text;}
void println(const char* text){print(text);output+="\r\n";}
};
struct MyMesh {
Port port;bool _terminal_mode=true;
Port& terminalOutput(){return port;}
void help(){ @HELP@ }
};
int main(){
MyMesh node;
for(bool terminal : {false,true}){
node._terminal_mode=terminal;node.port.output.clear();node.help();
const auto& text=node.port.output;
assert(text.find("Commands:\r\n stats-core / stats-radio / stats-radio-diag / stats-packets\r\n")==0);
assert(text.find(" get public.key\r\n")!=std::string::npos);
assert(text.find(" get usb.debug\r\n set usb.debug <on|off>\r\n")!=std::string::npos);
#if MESH_USB_CONSOLE_COOPERATIVE
assert(text.find(" get usb.watchdog\r\n get usb.watchdog.last\r\n set usb.watchdog <off|on|auto> (saved; default auto)\r\n")!=std::string::npos);
#else
assert(text.find("usb.watchdog")==std::string::npos);
#endif
assert(text.find(" reboot\r\n ver\r\n")!=std::string::npos);
assert(text.find(" +++MESHCORE-TERM-STOP\r\n")!=std::string::npos ? terminal : !terminal);
assert(text.find(" disconnect (closes the network terminal)\r\n")!=std::string::npos ? !terminal : terminal);
}
}
'''.replace('@HELP@', source[start:end])
features = ['MESH_USB_LOGGING_AVAILABLE', 'COMPANION_FEATURE_READER',
'COMPANION_FEATURE_MEMORY_DIAGNOSTICS', 'COMPANION_FEATURE_NOTIFICATIONS',
'COMPANION_RADIO_FULL', 'COMPANION_EXCLUSIVE_WIFI_BLE',
'WITH_WEBCONFIG', 'MESH_PRIMARY_ESPNOW', 'COMPANION_FEATURE_TEMP_RADIO',
'COMPANION_FEATURE_OTA_CLI', 'BLE_PIN_CODE', 'ESP32', 'NRF52_PLATFORM',
'EXTRAFS']
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
self.prepare(work)
for native in (0, 1):
with self.subTest(native=native):
self.compile_and_run(work, harness,
[flag + '=1' for flag in features] + ['WIFI_SSID="test"',
'MESH_USB_CONSOLE_COOPERATIVE=' + str(native)])
if __name__ == '__main__':
unittest.main()
@@ -24,7 +24,7 @@ HARNESS = r'''
#include <cstdint>
#include <helpers/FileRead.h>
#define MESH_ESP32_TINYUSB_NONBLOCKING 1
#define MESH_ESP32_USB_CONSOLE_COOPERATIVE 1
#define MESH_USB_CONSOLE_COOPERATIVE 1
#define MAX_ROUTE_HASH_BYTES 3
#define PACKET_LOG_FILE "/packet_log"
static void require(bool ok, const char* what) {
@@ -249,7 +249,7 @@ class CooperativeOutputTest(unittest.TestCase):
with self.subTest(role=role), tempfile.TemporaryDirectory() as temporary:
text = (ROOT / f"examples/{role}/MyMesh.cpp").read_text()
dump_start = text.index("void MyMesh::dumpLogFile()")
dump_end = text.index("\n#if MESH_ESP32_USB_CONSOLE_COOPERATIVE\nbool MyMesh::hasPendingSerialOutput", dump_start)
dump_end = text.index("\n#if MESH_USB_CONSOLE_COOPERATIVE\nbool MyMesh::hasPendingSerialOutput", dump_start)
pump_start = text.index("bool MyMesh::hasPendingSerialOutput()", dump_end)
pump_end = text.index("\n#endif", pump_start)
methods = text[dump_start:dump_end] + "\n" + text[pump_start:pump_end]
+1
View File
@@ -32,6 +32,7 @@ class Esp32TinyUsbNonblockingTest(unittest.TestCase):
f"-I{FIXTURE / 'mocks'}", f"-I{ROOT / 'src'}",
str(FIXTURE / "test_esp32_tinyusb_nonblocking.cpp"),
str(ROOT / "src/helpers/UsbLogging.cpp"),
str(ROOT / "src/helpers/UsbLoggingClientActivity.cpp"),
"-o", str(binary),
]
if companion:
+7 -2
View File
@@ -24,6 +24,8 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
r"return buffered_esp32_tinyusb_terminal_port;\s+"
r"#elif MESH_ESP32_HWCDC_SESSION_GUARD\s+"
r"return guarded_esp32_hwcdc_port;\s+"
r"#elif MESH_NRF52_USB_CONSOLE_COOPERATIVE\s+"
r"return buffered_primary_usb_terminal_port;\s+"
r"#else\s+return Serial;\s+#endif",
)
@@ -140,15 +142,18 @@ class Esp32TinyUsbRoleHygieneTest(unittest.TestCase):
def test_sleep_keeps_raw_flush_only_for_other_esp32_transports(self):
text = source("src/helpers/ESP32Board.cpp")
shutdown = text[text.index("void ESP32Board::shutdownPeripherals(") :
text.index("void ESP32Board::powerOff(")]
sleep = text[text.index("void ESP32Board::enterDeepSleep(") :]
self.assertIn("shutdownPeripherals();", sleep)
guard = re.search(
r"#if MESH_ESP32_USB_CONSOLE_COOPERATIVE\s+"
r"mesh::serviceUsbTerminalPort\(\);\s+"
r"#else\s+Serial\.flush\(\);\s+#endif",
sleep,
shutdown,
)
self.assertIsNotNone(guard)
self.assertEqual(sleep.count("Serial.flush();"), 1)
self.assertEqual(shutdown.count("Serial.flush();"), 1)
def test_large_file_dumps_are_bounded_and_cancelable(self):
for role in ROLES:
+5 -5
View File
@@ -30,7 +30,7 @@ class Esp32UsbSerialHygieneTest(unittest.TestCase):
r"\bSerial\.(?:print|println|printf|write)\s*\(",
relative,
)
self.assertIn("mesh::usbLoggingPort()", text, relative)
self.assertRegex(text, r"mesh::usb(?:Logging|Debug)Port\(\)", relative)
def test_v4_companion_uses_usb_serial_jtag_mode(self):
platformio = source("variants/heltec_v4/platformio.ini")
@@ -134,7 +134,7 @@ class Esp32UsbSerialHygieneTest(unittest.TestCase):
self.assertNotIn("Serial.setTxBufferSize(", helper)
self.assertIn("while (Serial.read() >= 0)", helper)
self.assertIn(
"setPlatformDebugOutputEnabled(isUsbLoggingEnabled());", purge
"setPlatformDebugOutputEnabled(isUsbDebugLoggingEnabled());", purge
)
self.assertIn(
"esp32_hwcdc_self_reset_guard.expectSelfResetBurst()", purge
@@ -325,7 +325,7 @@ class Esp32UsbSerialHygieneTest(unittest.TestCase):
)
self.assertIn("Stream& output = mesh::usbLoggingPort();", text)
def test_framework_diagnostics_follow_same_runtime_gate(self):
def test_framework_diagnostics_require_master_and_debug_gates(self):
text = source("src/helpers/UsbLogging.cpp")
self.assertIn("Serial.setDebugOutput(enabled);", text)
@@ -333,14 +333,14 @@ class Esp32UsbSerialHygieneTest(unittest.TestCase):
text.index("void setUsbLoggingEnabled(") :
text.index("bool saveUsbLoggingBootPreference(")
]
self.assertIn("setPlatformDebugOutputEnabled(enabled);", setter)
self.assertIn("setPlatformDebugOutputEnabled(isUsbDebugLoggingEnabled());", setter)
begin = text[
text.index("void beginUsbLoggingPort(") :
text.index("void serviceUsbLoggingPort(")
]
self.assertIn(
"setPlatformDebugOutputEnabled(isUsbLoggingEnabled());", begin
"setPlatformDebugOutputEnabled(isUsbDebugLoggingEnabled());", begin
)
def test_expected_fresh_nvs_state_is_silent(self):
+78
View File
@@ -25,6 +25,80 @@ function hex(text) {
assert.strictEqual(publicOnly.public.radioId, "000102030405060708090A0B0C0D0E0F");
assert.strictEqual(publicOnly.public.firmware, "1.17.1.5");
assert.strictEqual(publicOnly.acl.length, 0);
assert.strictEqual(publicOnly.public.protocol, "MGR1");
assert.strictEqual(publicOnly.public.usbLogging, null);
assert.strictEqual(publicOnly.public.usbWatchdogLast, null);
// Public MGR2 parsing is additive; actual MGR2 SIV ciphertext is checked
// cross-language by test_management_report.py using the production encoder.
const legacy = Buffer.from(decoder.EXAMPLE_PAGE, "hex");
const currentHeader = Buffer.from(legacy.subarray(0, 83)); currentHeader[3] = 0x32;
const usb = Buffer.alloc(15);
usb.writeUInt16LE(0x07ff, 0); usb[2] = 2 | (8 << 2);
usb.writeUInt32LE(604800, 3); usb.writeUInt32LE(0xffffffff, 7);
usb.writeUInt32LE(1209600, 11);
const current = Buffer.concat([currentHeader, usb, legacy.subarray(83)]);
const currentPublic = await decoder.decodeManagement(current.toString("hex"), "");
assert.strictEqual(currentPublic.public.protocol, "MGR2");
assert.strictEqual(currentPublic.public.usbWatchdogLast, null);
assert.strictEqual(currentPublic.public.usbLogging.supported, true);
assert.strictEqual(currentPublic.public.usbLogging.persistenceReady, true);
assert.strictEqual(currentPublic.public.usbLogging.loggerActive, true);
assert.strictEqual(currentPublic.public.usbLogging.stage, 2);
assert.strictEqual(currentPublic.public.usbLogging.backoffStep, 8);
assert.strictEqual(currentPublic.public.usbLogging.retrySeconds, 604800);
assert.strictEqual(currentPublic.public.usbLogging.inactiveSeconds, 0xffffffff);
assert.strictEqual(currentPublic.public.usbLogging.watchdogAuto, true);
assert.strictEqual(currentPublic.public.usbLogging.autoConnectedSeconds, 1209600);
const unsupported = Buffer.from(current); unsupported.writeUInt16LE(0, 83);
assert.strictEqual((await decoder.decodeManagement(unsupported.toString("hex"), "")).public.usbLogging.supported, false);
const openReaderOnly = Buffer.from(current); openReaderOnly[84] &= ~4;
const readerWithoutLogger = (await decoder.decodeManagement(openReaderOnly.toString("hex"), "")).public.usbLogging;
assert.strictEqual(readerWithoutLogger.hostConnected, true);
assert.strictEqual(readerWithoutLogger.readerConnected, true);
assert.strictEqual(readerWithoutLogger.loggerActive, false);
const badCount = Buffer.from(current); badCount[82] = 6;
await assert.rejects(decoder.decodeManagement(badCount.toString("hex"), ""), /bounds/);
await assert.rejects(decoder.decodeManagement(current.subarray(0, 99).toString("hex"), ""), /complete/);
for (const [offset, value] of [[84, 8], [85, 0xc0]]) {
const reserved = Buffer.from(current); reserved[offset] = value;
await assert.rejects(decoder.decodeManagement(reserved.toString("hex"), ""), /Reserved/);
}
// Public MGR3 fields use their own layout; production ciphertext for all
// three versions is authenticated cross-language by the Python host test.
const mgr3Header = Buffer.from(current.subarray(0, 98)); mgr3Header[3] = 0x33;
const event = Buffer.alloc(13); event[0] = 0xbf;
event.writeUInt32LE(1700000001, 1); event.writeUInt32LE(777, 5); event.writeUInt32LE(42, 9);
const mgr3 = Buffer.concat([mgr3Header, event, current.subarray(98)]);
const mgr3Public = await decoder.decodeManagement(mgr3.toString("hex"), "");
assert.strictEqual(mgr3Public.public.protocol, "MGR3");
assert.strictEqual(mgr3Public.public.usbLogging.loggerActive, true);
assert.deepStrictEqual(mgr3Public.public.usbWatchdogLast, {
reasons: 15, reasonNames: ["host-absent", "reader-absent", "tx-stalled", "client-inactive"],
actionCode: 3, action: "reboot-requested", persisted: true,
epoch: 1700000001, uptimeSeconds: 777, sequence: 42,
});
const none = Buffer.from(mgr3); none.fill(0, 98, 111);
assert.strictEqual((await decoder.decodeManagement(none.toString("hex"), "")).public.usbWatchdogLast, null);
for (let action = 1; action <= 4; ++action) {
const ramOnly = Buffer.from(mgr3); ramOnly[98] = 15 | (action << 4); ramOnly.writeUInt32LE(0, 99);
const decodedEvent = (await decoder.decodeManagement(ramOnly.toString("hex"), "")).public.usbWatchdogLast;
assert.strictEqual(decodedEvent.actionCode, action);
assert.strictEqual(decodedEvent.persisted, false);
assert.strictEqual(decodedEvent.epoch, 0);
}
for (const code of [0x80, 0x8f, 0xdf, 0xef, 0xff]) {
const invalid = Buffer.from(mgr3); invalid[98] = code;
await assert.rejects(decoder.decodeManagement(invalid.toString("hex"), ""), /Invalid/);
}
const invalidSequence = Buffer.from(mgr3); invalidSequence.writeUInt32LE(0, 107);
await assert.rejects(decoder.decodeManagement(invalidSequence.toString("hex"), ""), /Invalid/);
const fiveEntries = Buffer.from(mgr3); fiveEntries[82] = 5;
await assert.rejects(decoder.decodeManagement(fiveEntries.toString("hex"), ""), /bounds/);
for (const size of [99, 110, 126]) {
await assert.rejects(decoder.decodeManagement(mgr3.subarray(0, size).toString("hex"), ""), /complete/);
}
const decoded = await decoder.decodeManagement(
decoder.EXAMPLE_PAGE,
@@ -37,6 +111,10 @@ function hex(text) {
assert.strictEqual(decoded.acl[0].fingerprint, "AABBCCDDEEFF001122334455");
assert.strictEqual(decoded.acl[0].administrator, true);
assert.strictEqual(decoded.acl[0].otaSigner, true);
const wrapped = decoder.EXAMPLE_PAGE.match(/.{1,64}/g).join("\n");
assert.strictEqual((await decoder.decodeManagement(wrapped, decoder.EXAMPLE_PASSWORD)).authenticated, true);
const duplicateLines = decoder.EXAMPLE_PAGE + "\n" + decoder.EXAMPLE_PAGE;
assert.strictEqual((await decoder.decodeManagement(duplicateLines, decoder.EXAMPLE_PASSWORD)).acl.length, 1);
const groupData = "1A00" + decoder.EXAMPLE_PAGE;
const packet = await decoder.decodeManagement(groupData, decoder.EXAMPLE_PASSWORD);
+165 -4
View File
@@ -25,7 +25,7 @@ class ManagementTests(unittest.TestCase):
cls.exe = Path(cls.work.name) / "management-test"
sources = ["AES128.cpp", "AESCommon.cpp", "BlockCipher.cpp", "Crypto.cpp", "SHA256.cpp", "Hash.cpp"]
host_define = [] if os.name == "nt" else ["-DHOST_BUILD"]
sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"]
sanitizers = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-omit-frame-pointer"]
cmd = [shutil.which("g++") or "g++", "-std=c++17", "-O1", "-g", "-Wall", "-Wextra"] + host_define + [
*sanitizers, "-I", str(cls.crypto), "-I", str(ROOT / "src"),
str(ROOT / "src/helpers/ManagementReport.cpp"),
@@ -33,7 +33,11 @@ class ManagementTests(unittest.TestCase):
cmd += [str(cls.crypto / p) for p in sources] + ["-o", str(cls.exe)]
subprocess.run(cmd, check=True, capture_output=True, text=True)
result = subprocess.run([str(cls.exe)], check=True, capture_output=True, text=True)
cls.pages = [bytes.fromhex(line) for line in result.stdout.splitlines()]
emitted = [bytes.fromhex(line) for line in result.stdout.splitlines()]
cls.pages = [page for page in emitted if page[:4] == b"MGR3" and int.from_bytes(page[20:24], "little") == 42]
cls.mgr2_pages = [page for page in emitted if page[:4] == b"MGR2"]
cls.legacy_pages = [page for page in emitted if page[:4] == b"MGR1"]
cls.empty_event_pages = [page for page in emitted if page[:4] == b"MGR3" and int.from_bytes(page[20:24], "little") == 43]
# Compile the unchanged production runtime and transaction writer with
# a memory filesystem/radio in place of the hardware-facing headers.
fixture = ROOT / "test/fixtures/management"
@@ -61,24 +65,180 @@ class ManagementTests(unittest.TestCase):
self.assertEqual(decoded["sequence"], 42)
self.assertEqual(len(decoded["acl"]), 36)
self.assertEqual(decoded["firmware_version"], "1.17.1.5")
self.assertEqual(decoded["protocol"], "MGR3")
self.assertEqual(len(self.pages), 9)
usb = decoded["usb_logging"]
self.assertTrue(usb["supported"] and usb["recovery_deferred"])
self.assertTrue(usb["host_connected"] and usb["reader_connected"])
self.assertTrue(usb["logger_active"])
self.assertEqual(usb["stage"], 2)
self.assertEqual(usb["backoff_step"], 8)
self.assertEqual(usb["retry_seconds"], 604800)
self.assertEqual(usb["inactive_seconds"], 0xffffffff)
self.assertTrue(usb["watchdog_auto"])
self.assertEqual(usb["auto_connected_seconds"], 1209600)
self.assertEqual(decoded["usb_watchdog_last"], dict(
reasons=15, reason_names=["host-absent", "reader-absent", "tx-stalled", "client-inactive"],
action_code=3, action="reboot-requested", persisted=True,
epoch=1700000001, uptime_seconds=777, sequence=42))
self.assertNotIn("manifest_id", decoded)
def test_legacy_report_still_decodes_without_inventing_usb_status(self):
decoded = report.decode_report(self.legacy_pages, "management test password")
self.assertEqual(decoded["protocol"], "MGR1")
self.assertEqual(len(decoded["acl"]), 36)
self.assertIsNone(decoded["usb_logging"])
self.assertIsNone(decoded["usb_watchdog_last"])
with self.assertRaises(ValueError):
report.decode_report([self.legacy_pages[0]] + self.pages[1:], "management test password")
def test_mgr2_crypto_still_decodes_without_inventing_event(self):
decoded = report.decode_report(self.mgr2_pages, "management test password")
self.assertEqual(decoded["protocol"], "MGR2")
self.assertEqual(len(decoded["acl"]), 36)
self.assertEqual(len(self.mgr2_pages), 8)
self.assertEqual(decoded["usb_logging"]["retry_seconds"], 604800)
self.assertIsNone(decoded["usb_watchdog_last"])
def test_current_none_event_with_empty_acl_is_authenticated(self):
decoded = report.decode_report(self.empty_event_pages, "management test password")
self.assertEqual(decoded["protocol"], "MGR3")
self.assertEqual(decoded["acl"], [])
self.assertIsNone(decoded["usb_watchdog_last"])
def test_current_reports_decode_in_browser_with_real_production_ciphertext(self):
node = shutil.which("node")
self.assertIsNotNone(node, "node is required for cross-language management decoding")
script = """
const assert = require('assert');
globalThis.crypto = require('crypto').webcrypto;
const decoder = require(process.argv[1]);
let data = '';
process.stdin.on('data', chunk => { data += chunk; });
process.stdin.on('end', async () => {
try {
const captures = JSON.parse(data);
const pages = captures.MGR3;
for (const magic of ['MGR1', 'MGR2']) {
const legacy = await decoder.decodeManagement(captures[magic].join('\\n'), 'management test password');
assert(legacy.authenticated && legacy.complete);
assert.strictEqual(legacy.acl.length, 36);
assert.strictEqual(legacy.public.protocol, magic);
assert.strictEqual(legacy.pageCount, magic === 'MGR1' ? 6 : 8);
assert.strictEqual(legacy.public.usbWatchdogLast, null);
assert.strictEqual(legacy.public.usbLogging === null, magic === 'MGR1');
}
const empty = await decoder.decodeManagement(captures.empty.join('\\n'), 'management test password');
assert(empty.authenticated && empty.complete && !empty.acl.length);
assert.strictEqual(empty.public.protocol, 'MGR3');
assert.strictEqual(empty.public.usbWatchdogLast, null);
const result = await decoder.decodeManagement(pages.join('\\n'), 'management test password');
assert(result.authenticated && result.complete);
assert.strictEqual(result.acl.length, 36);
assert.strictEqual(result.pageCount, 9);
assert.strictEqual(result.public.protocol, 'MGR3');
assert.strictEqual(result.public.usbLogging.retrySeconds, 604800);
assert.strictEqual(result.public.usbLogging.inactiveSeconds, 0xffffffff);
assert.strictEqual(result.public.usbLogging.hostConnected, true);
assert.strictEqual(result.public.usbLogging.readerConnected, true);
assert.strictEqual(result.public.usbLogging.loggerActive, true);
assert.strictEqual(result.public.usbLogging.watchdogAuto, true);
assert.strictEqual(result.public.usbLogging.autoConnectedSeconds, 1209600);
assert.strictEqual(result.public.usbWatchdogLast.epoch, 1700000001);
assert.strictEqual(result.public.usbWatchdogLast.uptimeSeconds, 777);
assert.strictEqual(result.public.usbWatchdogLast.sequence, 42);
assert.strictEqual(result.public.usbWatchdogLast.reasons, 15);
assert.strictEqual(result.public.usbWatchdogLast.action, 'reboot-requested');
assert.strictEqual(result.public.usbWatchdogLast.persisted, true);
const raw = pages.map(page => '1900' + page + '00'.repeat((3 + Math.ceil((page.length / 2 - 3) / 16) * 16) - page.length / 2));
const routed = await decoder.decodeManagement(raw.join('\\n'), 'management test password');
assert(routed.authenticated && routed.complete);
assert.strictEqual(routed.public.envelope.route, 'flood');
const mutated = Buffer.from(pages[0], 'hex'); mutated[87] ^= 1;
await assert.rejects(decoder.decodeManagement(mutated.toString('hex'), 'management test password'), /Password is wrong/);
const changedLogger = Buffer.from(pages[0], 'hex'); changedLogger[84] ^= 4;
await assert.rejects(decoder.decodeManagement(changedLogger.toString('hex'), 'management test password'), /Password is wrong/);
for (let offset = 98; offset < 111; ++offset) {
const changedEvent = Buffer.from(pages[0], 'hex'); changedEvent[offset] ^= 1;
await assert.rejects(decoder.decodeManagement(changedEvent.toString('hex'), 'management test password'), /Password is wrong|Invalid/);
}
const changedSnapshot = Buffer.from(pages[1], 'hex'); changedSnapshot[99] ^= 1;
await assert.rejects(decoder.decodeManagement(pages[0] + '\\n' + changedSnapshot.toString('hex'), ''), /same snapshot/);
} catch (error) { console.error(error); process.exitCode = 1; }
});
"""
import json
subprocess.run([node, "-e", script, str(ROOT / "docs/_javascript/management_decoder.js")],
input=json.dumps({"MGR3": [page.hex() for page in self.pages],
"MGR2": [page.hex() for page in self.mgr2_pages],
"MGR1": [page.hex() for page in self.legacy_pages],
"empty": [page.hex() for page in self.empty_event_pages]}),
text=True, check=True, timeout=30)
def test_runtime_storage_scheduling_rollover_and_failures(self):
subprocess.run([str(self.runtime)], check=True)
def test_tampering_and_wrong_password(self):
for position in (4, 20, 28, 74, 76, 83, -1):
for position in (4, 20, 28, 74, 76, 83, 85, 86, 90, 94, *range(98, 111), 111, -1):
changed = bytearray(self.pages[0]); changed[position] ^= 1
with self.subTest(position=position), self.assertRaises(ValueError):
report.decode_report([bytes(changed)] + self.pages[1:], "management test password")
with self.assertRaises(ValueError):
report.decode_report(self.pages, "incorrect management password")
def test_logger_activity_is_authenticated_separately_from_connection(self):
changed = bytearray(self.pages[0]); changed[84] ^= 4
public = report._usb_status(changed)
self.assertTrue(public["host_connected"] and public["reader_connected"])
self.assertFalse(public["logger_active"])
with self.assertRaises(ValueError):
report.decode_page(bytes(changed), "management test password")
def test_page_omission_duplicate_mixed(self):
for pages in ([], self.pages[:-1], self.pages[1:] + [self.pages[1]], self.pages + [self.pages[0]]):
with self.assertRaises(ValueError):
report.decode_report(pages, "management test password")
def test_current_size_page_bounds_and_unknown_version_are_rejected(self):
for offset, value in ((3, ord("4")), (79, 8), (81, 6), (82, 5), (80, 37), (84, 8), (85, 0xc0)):
changed = bytearray(self.pages[0]); changed[offset] = value
with self.subTest(offset=offset), self.assertRaises(ValueError):
report.decode_page(bytes(changed), "management test password")
for payload in (self.pages[0][:-1], self.pages[0] + b"\x00", self.pages[0][:99]):
with self.assertRaises(ValueError):
report.decode_page(payload, "management test password")
def test_event_validation_and_advisory_zero_time(self):
for code in (0x80, 0x8f, 0xdf, 0xef, 0xff):
changed = bytearray(self.pages[0]); changed[98] = code
with self.subTest(code=code), self.assertRaises(ValueError):
report.decode_page(bytes(changed), "management test password")
changed = bytearray(self.pages[0]); changed[107:111] = bytes(4)
with self.assertRaises(ValueError):
report.decode_page(bytes(changed), "management test password")
changed = bytearray(self.pages[0]); changed[99:103] = bytes(4)
self.assertEqual(report._watchdog_event(changed)["epoch"], 0)
for action in range(1, 5):
changed[98] = 15 | (action << 4)
event = report._watchdog_event(changed)
self.assertEqual(event["action_code"], action)
self.assertFalse(event["persisted"])
def test_authenticated_event_mismatch_is_not_one_snapshot(self):
from Crypto.Cipher import AES
page = self.pages[1]
key = report.derive(report.password_key("management test password"), "MeshCore-MGR1-SIV", page[4:20])
cipher = AES.new(key, AES.MODE_SIV); cipher.update(page[:111])
private = cipher.decrypt_and_verify(page[111:-16], page[-16:])
header = bytearray(page[:111]); header[99] ^= 1
cipher = AES.new(key, AES.MODE_SIV); cipher.update(header)
ciphertext, tag = cipher.encrypt_and_digest(private)
changed = bytes(header) + ciphertext + tag
self.assertEqual(report.decode_page(changed, "management test password"),
report.decode_page(page, "management test password"))
with self.assertRaisesRegex(ValueError, "mixed report snapshots"):
report.decode_report([self.pages[0], changed] + self.pages[2:], "management test password")
def test_fingerprints_and_password_length(self):
a = report.fingerprint("management test password", bytes(16), bytes(32))
b = report.fingerprint("management test password", bytes([1]) + bytes(15), bytes(32))
@@ -111,7 +271,8 @@ class ManagementTests(unittest.TestCase):
padded = page + bytes(3 + ((len(page) - 3 + 15) // 16) * 16 - len(page))
message = dict(type="PACKET", direction="rx", raw=(b"\x19\x00" + padded).hex())
self.assertEqual(report.mqtt_payload(message), page)
message["raw"] = (b"\x19\x00" + padded[:-1] + b"\x01").hex()
malformed = padded[:-1] + b"\x01" if len(padded) > len(page) else padded + b"\x00"
message["raw"] = (b"\x19\x00" + malformed).hex()
self.assertIsNone(report.mqtt_payload(message))
@@ -118,6 +118,7 @@ bool canAccess(void* opaque) {
CapacityStream logging_port;
mesh::WholeRecordNonBlockingStream<> bounded_logging_port(logging_port);
bool logging_enabled = true;
bool debug_enabled = true;
} // namespace
@@ -131,6 +132,10 @@ bool isUsbLoggingEnabled() {
return logging_enabled;
}
bool isUsbDebugEnabled() {
return debug_enabled;
}
Stream& usbLoggingPort() {
return bounded_logging_port;
}
@@ -141,6 +146,7 @@ class Nrf52DebugOutputTest : public testing::Test {
protected:
void SetUp() override {
logging_enabled = true;
debug_enabled = true;
logging_port.reset(256);
}
};
@@ -196,6 +202,16 @@ TEST_F(Nrf52DebugOutputTest, DisabledLoggingDoesNotTouchThePort) {
EXPECT_EQ(logging_port.write_calls_, 0);
}
TEST_F(Nrf52DebugOutputTest, PacketOutputDoesNotEnableDebugVerbosity) {
debug_enabled = false;
MESH_DEBUG_PRINTLN("quiet");
BRIDGE_DEBUG_PRINTLN("quiet");
POWERSAVING_DEBUG_PRINTLN("quiet");
EXPECT_EQ(logging_port.available_calls_, 0);
EXPECT_EQ(logging_port.write_calls_, 0);
EXPECT_TRUE(mesh::isUsbLoggingEnabled());
}
TEST_F(Nrf52DebugOutputTest, ReentrantDebugOutputIsDroppedWithoutWaiting) {
logging_port.reenter_on_write_ = true;
+15 -3
View File
@@ -55,6 +55,10 @@ static uint32_t baud = 115200;
static void tud_cdc_get_line_coding(cdc_line_coding_t* coding) { coding->bit_rate = baud; }
static void TinyUSB_Port_EnterDFU() { dfu = true; }
namespace mesh {
static std::atomic<bool> usb_logging_tx_waiting{false};
static bool isUsbLoggingEnabled() { return true; }
static void clearUsbLoggingClientActivity() {}
@ATTEMPT@
@GATES@
static uint32_t primary_usb_terminal_taken_reset_generation = 0;
@WRITE@
@@ -152,6 +156,7 @@ int main() {
class Nrf52UsbClientTest(unittest.TestCase):
def test_stop_token_reports_shared_logging_conflict(self):
main = (ROOT / 'examples/companion_radio/main.cpp').read_text()
stop = function(main, 'if (strcmp(usb_terminal_line, USB_TERMINAL_STOP_TOKEN) == 0)')
@@ -294,8 +299,10 @@ int main() {
def test_real_usb_callbacks_and_transport_with_both_client_styles(self):
start = USB.index('static std::atomic<uint32_t> primary_usb_reset_generation')
gates = USB[start:USB.index('\n#endif', start)]
end_start = USB.index('static void endPrimaryUsbHostSession(', start)
gates = USB[start:end_start] + function(USB, 'static void endPrimaryUsbHostSession(')
source = HARNESS.replace('@GATES@', gates)
source = source.replace('@ATTEMPT@', function(USB, 'static void noteUsbLoggingTxAttempt('))
for marker, signature in (
('@WRITE@', 'static size_t writeTinyUsbCdcOnce('),
('@COMPLETE@', 'static void completePrimaryUsbSessionReset('),
@@ -308,10 +315,15 @@ int main() {
with tempfile.TemporaryDirectory() as directory:
cpp = Path(directory) / 'usb-client.cpp'
cpp.write_text(source)
for dual in (False, True):
with self.subTest(dedicated_logging=dual):
for dual, ordinary in ((False, False), (True, False), (False, True)):
with self.subTest(dedicated_logging=dual, ordinary_role=ordinary):
binary = Path(directory) / ('usb-client-' + str(dual))
flags = ['-DMESH_DUAL_CDC_LOGGING=1'] if dual else []
tested_source = source
if ordinary:
flags += ['-DUSE_TINYUSB=1']
tested_source = source.replace('#define ENABLE_USB_INTERFACE 1\n', '')
cpp.write_text(tested_source)
subprocess.run(['g++', '-std=c++17', *flags, str(cpp), '-o', str(binary)], check=True)
subprocess.run([str(binary)], check=True)
+42
View File
@@ -0,0 +1,42 @@
"""Compile the complete native nRF52 USB facade with stalled endpoint mocks."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / 'test/fixtures/nrf52_usb_console'
class Nrf52UsbConsoleTest(unittest.TestCase):
def test_actual_console_and_session_callbacks(self):
compiler = shutil.which('g++') or shutil.which('clang++')
if not compiler:
self.skipTest('a host C++17 compiler is required')
sanitizer = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
with tempfile.TemporaryDirectory(prefix='nrf52-usb-console-') as directory:
for companion in (False, True):
with self.subTest(companion=companion):
binary = Path(directory) / ('console-' + str(companion))
built = subprocess.run([
compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror',
*sanitizer, '-DARDUINO', '-DNRF52_PLATFORM', '-DUSE_TINYUSB',
'-DMESH_DEBUG=1', *(['-DENABLE_USB_INTERFACE'] if companion else []),
'-I' + str(FIXTURE / 'mocks'), '-I' + str(ROOT / 'src'),
str(FIXTURE / 'test_console.cpp'),
*[str(ROOT / 'src/helpers' / file) for file in (
'UsbLogging.cpp', 'UsbLoggingClientActivity.cpp',
'UsbLoggingLineStateOverride.cpp')],
'-o', str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stderr)
tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(tested.returncode, 0, tested.stderr)
self.assertIn('native nRF52 console transport passed', tested.stdout)
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -294,7 +294,7 @@ class Nrf52UsbLoggingContractTest(unittest.TestCase):
session_end = source[source.index(
"static void endPrimaryUsbHostSession("
):source.index("#endif", source.index(
):source.index("\n}\n#endif", source.index(
"static void endPrimaryUsbHostSession("
))]
self.assertIn("primary_usb_line_state_dtr.exchange(", session_end)
+37 -29
View File
@@ -171,41 +171,49 @@ TEST(ManagementRouting, GroupDataForBothRoutesKeepsPublicHeaderAndLegacyLength)
TraceTestRadio radio; TraceTestClock clock; TraceTestRNG rng; TraceTestRTC rtc;
TraceTestTables tables; StaticPoolPacketManager pool(8);
TraceTestMesh mesh(radio, clock, rng, rtc, pool, tables);
uint8_t raw[mesh::management::HEADER + mesh::management::TAG] = {};
memcpy(raw, "MGR1", 4); raw[79] = 1;
uint8_t raw[mesh::management::CURRENT_HEADER + mesh::management::TAG] = {};
const uint8_t path[] = {0x12, 0xab};
uint8_t scope_key[16]; memset(scope_key, 0x42, sizeof(scope_key));
for (bool flood : {false, true}) {
auto* p = mesh.createRawData(raw, sizeof(raw)); ASSERT_NE(nullptr, p);
ASSERT_TRUE(mesh.sendManagementData(p, flood, path, 2, 2,
flood ? scope_key : nullptr));
EXPECT_EQ(PAYLOAD_TYPE_GRP_DATA, p->getPayloadType());
EXPECT_EQ(flood, p->isRouteFlood());
EXPECT_EQ(0, memcmp(p->payload, raw, sizeof(raw)));
EXPECT_EQ(0u, (p->payload_len - 3u) % 16u);
EXPECT_TRUE(mesh::management::validPage(p->payload, p->payload_len, true));
EXPECT_EQ(flood ? 0 : 2, p->getPathHashCount());
if (!flood) EXPECT_EQ(0, memcmp(p->path, path, 2));
for (const char* magic : {"MGR1", "MGR2", "MGR3"}) {
memset(raw, 0, sizeof(raw));
memcpy(raw, magic, 4); raw[79] = 1;
const size_t size = mesh::management::headerSize(raw) + mesh::management::TAG;
for (bool flood : {false, true}) {
auto* p = mesh.createRawData(raw, size); ASSERT_NE(nullptr, p);
ASSERT_TRUE(mesh.sendManagementData(p, flood, path, 2, 2,
flood ? scope_key : nullptr));
EXPECT_EQ(PAYLOAD_TYPE_GRP_DATA, p->getPayloadType());
EXPECT_EQ(flood, p->isRouteFlood());
EXPECT_EQ(0, memcmp(p->payload, raw, size));
EXPECT_EQ(0u, (p->payload_len - 3u) % 16u);
EXPECT_TRUE(mesh::management::validPage(p->payload, p->payload_len, true));
EXPECT_EQ(flood ? 0 : 2, p->getPathHashCount());
if (!flood) EXPECT_EQ(0, memcmp(p->path, path, 2));
}
}
}
TEST(ManagementRouting, FloodForwardingDoesNotNeedAKeyAndStillHonorsFilters) {
TraceTestRadio radio; TraceTestClock clock; TraceTestRNG rng; TraceTestRTC rtc;
TraceTestTables tables; StaticPoolPacketManager pool(8);
TraceTestMesh mesh(radio, clock, rng, rtc, pool, tables);
mesh.forwardFloods = true;
mesh::Packet p;
p.header = (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD;
p.setPathHashSizeAndCount(1, 0);
p.payload_len = mesh::management::floodSize(mesh::management::HEADER + mesh::management::TAG);
memset(p.payload, 0, p.payload_len);
memcpy(p.payload, "MGR1", 4);
p.payload[79] = 1; // one page containing zero ACL entries
ASSERT_TRUE(mesh::management::validPage(p.payload, p.payload_len, true));
EXPECT_NE(ACTION_RELEASE, mesh.receivePacket(&p));
EXPECT_FALSE(mesh.groupPacketObserved); // never delivered as decrypted channel data
mesh.rejectFloods = true;
EXPECT_EQ(ACTION_RELEASE, mesh.receivePacket(&p));
for (const char* magic : {"MGR1", "MGR2", "MGR3"}) {
TraceTestRadio radio; TraceTestClock clock; TraceTestRNG rng; TraceTestRTC rtc;
TraceTestTables tables; StaticPoolPacketManager pool(8);
TraceTestMesh mesh(radio, clock, rng, rtc, pool, tables);
mesh.forwardFloods = true;
mesh::Packet p;
p.header = (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT) | ROUTE_TYPE_FLOOD;
p.setPathHashSizeAndCount(1, 0);
memset(p.payload, 0, sizeof(p.payload));
memcpy(p.payload, magic, 4);
p.payload_len = mesh::management::floodSize(
mesh::management::headerSize(p.payload) + mesh::management::TAG);
memset(p.payload + 4, 0, p.payload_len - 4);
p.payload[79] = 1; // one page containing zero ACL entries
ASSERT_TRUE(mesh::management::validPage(p.payload, p.payload_len, true));
EXPECT_NE(ACTION_RELEASE, mesh.receivePacket(&p));
EXPECT_FALSE(mesh.groupPacketObserved); // never delivered as decrypted channel data
mesh.rejectFloods = true;
EXPECT_EQ(ACTION_RELEASE, mesh.receivePacket(&p));
}
}
class RetryCodingRateRadio : public TraceTestRadio {
+195
View File
@@ -0,0 +1,195 @@
"""Exercise the real RAM-only USB stats lease and bounded CDC input parser."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
MOCKS = ROOT / 'test' / 'fixtures' / 'usb_logging_watchdog' / 'mocks'
HARNESS = r'''
#include <Arduino.h>
#include <helpers/UsbLoggingClientActivity.h>
#include <cassert>
#include <cstdio>
#include <initializer_list>
#include <string>
using Parser = mesh::UsbLoggingStatsLineParser;
constexpr uint32_t Lease = mesh::USB_LOGGING_CLIENT_LEASE_MS;
static unsigned consume(Parser& parser, const std::string& input) {
unsigned matched = 0;
for (const char byte : input) if (parser.consume(byte)) ++matched;
return matched;
}
static void check_exact_stats_command_recognition() {
assert(Lease == 15U * 60U * 1000U);
assert(!mesh::isUsbLoggingStatsCommand(nullptr));
for (const char* command : {"stats-core", "stats-radio", "stats-packets",
" \tstats-core\t ", "stats-radio\t", " stats-packets"}) {
assert(mesh::isUsbLoggingStatsCommand(command));
}
for (const char* command : {"", " ", "stats", "stats-core2", "stats-radio-diag",
"stats-packets suffix", "STATS-CORE", "get name",
"get usb.watchdog", "usb.logger heartbeat", "stats-core\r",
"stats-core\n", "stats-core\b", "stats-cor", "stats-core;"}) {
assert(!mesh::isUsbLoggingStatsCommand(command));
}
}
static void check_default_expiry_and_renewal() {
mock_watchdog_millis = 0;
mesh::clearUsbLoggingClientActivity();
assert(!mesh::isUsbLoggingClientActive());
for (const char* command : {"stats-core", "stats-radio", "stats-packets"}) {
mock_watchdog_millis = 100;
mesh::noteUsbLoggingStatsCommand(command);
assert(mesh::isUsbLoggingClientActive());
mock_watchdog_millis = 100 + Lease - 1;
assert(mesh::isUsbLoggingClientActive());
mock_watchdog_millis = 100 + Lease;
assert(!mesh::isUsbLoggingClientActive());
assert(!mesh::isUsbLoggingClientActive());
}
mesh::clearUsbLoggingClientActivity();
for (uint32_t cycle = 0; cycle != 100; ++cycle) {
mock_watchdog_millis = cycle * 300000U;
mesh::noteUsbLoggingStatsCommand("stats-core");
assert(mesh::isUsbLoggingClientActive());
mock_watchdog_millis += 300000U - 1;
assert(mesh::isUsbLoggingClientActive());
}
const auto last = 99U * 300000U;
mock_watchdog_millis = last + Lease - 1;
assert(mesh::isUsbLoggingClientActive());
mock_watchdog_millis = last + Lease;
assert(!mesh::isUsbLoggingClientActive());
// Expiration is latched. Multiple millis rollovers cannot revive an old poll.
for (uint64_t day = 1; day <= 150; ++day) {
mock_watchdog_millis = uint32_t(uint64_t(last) + day * 86400000ULL);
assert(!mesh::isUsbLoggingClientActive());
}
mock_watchdog_millis = last; // Exactly the old uint32 clock value after wrap.
assert(!mesh::isUsbLoggingClientActive());
}
static void check_invalid_input_never_extends_or_starts_a_lease() {
for (const char* command : {"stats-core extra", "stats-radio-diag", "ver", "",
"usb.logger heartbeat", "stats-packets\n"}) {
mock_watchdog_millis = 1000;
mesh::clearUsbLoggingClientActivity();
mesh::noteUsbLoggingStatsCommand(command);
assert(!mesh::isUsbLoggingClientActive());
mesh::noteUsbLoggingStatsCommand("stats-radio");
mock_watchdog_millis = 1000 + Lease - 1;
mesh::noteUsbLoggingStatsCommand(command);
assert(mesh::isUsbLoggingClientActive());
++mock_watchdog_millis;
assert(!mesh::isUsbLoggingClientActive());
}
mesh::noteUsbLoggingStatsCommand(nullptr);
assert(!mesh::isUsbLoggingClientActive());
}
static void check_session_clear_and_clock_wrap() {
mock_watchdog_millis = UINT32_MAX - 1000;
mesh::clearUsbLoggingClientActivity();
mesh::noteUsbLoggingStatsCommand(" \tstats-packets\t ");
const uint32_t started = mock_watchdog_millis;
mock_watchdog_millis = started + Lease - 1;
assert(mesh::isUsbLoggingClientActive());
mock_watchdog_millis = started + Lease;
assert(!mesh::isUsbLoggingClientActive());
mesh::noteUsbLoggingStatsCommand("stats-core");
assert(mesh::isUsbLoggingClientActive());
mesh::clearUsbLoggingClientActivity();
assert(!mesh::isUsbLoggingClientActive());
mesh::noteUsbLoggingStatsCommand("stats-radio");
assert(mesh::isUsbLoggingClientActive());
for (unsigned clear = 0; clear != 10000; ++clear) {
mesh::clearUsbLoggingClientActivity();
assert(!mesh::isUsbLoggingClientActive());
}
mesh::noteUsbLoggingStatsCommand("stats-core");
assert(mesh::isUsbLoggingClientActive());
// Deadline zero is reserved. This rare rollover expires only one ms early.
mesh::clearUsbLoggingClientActivity();
mock_watchdog_millis = uint32_t(0U - Lease);
const uint32_t zero_deadline_start = mock_watchdog_millis;
mesh::noteUsbLoggingStatsCommand("stats-core");
mock_watchdog_millis = zero_deadline_start + Lease - 2;
assert(mesh::isUsbLoggingClientActive());
mock_watchdog_millis = zero_deadline_start + Lease - 1;
assert(!mesh::isUsbLoggingClientActive());
mock_watchdog_millis = 0;
assert(!mesh::isUsbLoggingClientActive());
}
static void check_bounded_logging_endpoint_line_parser() {
Parser parser;
assert(consume(parser, "stats-core\r\n") == 1);
assert(consume(parser, "stats-radio\n") == 1);
assert(consume(parser, "stats-packets\r") == 1);
assert(consume(parser, "stats-core") == 0);
assert(consume(parser, "\n") == 1);
assert(consume(parser, " \tstats-radio \t\r\n") == 1);
assert(consume(parser, "stats-radio-diag\r\n") == 0);
assert(consume(parser, "stats-packets suffix\r\n") == 0);
assert(consume(parser, "\r\n\r\n") == 0);
assert(consume(parser, "stats-core") == 0);
parser.reset();
assert(consume(parser, "\n") == 0);
assert(consume(parser, std::string(21, ' ') + "stats-core\n") == 1); // 31 bytes.
assert(consume(parser, std::string(22, ' ') + "stats-core\n") == 0); // 32 bytes.
assert(consume(parser, std::string(1048576, 'x') + "stats-core\n") == 0);
assert(consume(parser, "stats-core\n") == 1); // Recover after rejected line.
assert(consume(parser, std::string("stats-core\0suffix\n", 18)) == 0);
assert(consume(parser, "stats-core\n") == 1);
assert(consume(parser, std::string(1, '\0') + "stats-core\n") == 0);
assert(consume(parser, "stats-core\n") == 1);
assert(consume(parser, "stats-core\b\n") == 0);
assert(consume(parser, "stats-core\x7f\n") == 0);
}
int main() {
check_exact_stats_command_recognition();
check_default_expiry_and_renewal();
check_invalid_input_never_extends_or_starts_a_lease();
check_session_clear_and_clock_wrap();
check_bounded_logging_endpoint_line_parser();
puts("USB stats client activity lease passed");
}
'''
class UsbLoggingClientActivityTest(unittest.TestCase):
def test_actual_client_activity_and_parser(self):
compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')
if compiler is None:
self.skipTest('a host C++17 compiler is required')
sanitizer_flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
with tempfile.TemporaryDirectory(prefix='usb-logging-client-') as directory:
source = Path(directory) / 'test.cpp'
binary = Path(directory) / 'test.exe'
source.write_text(HARNESS, encoding='utf-8')
built = subprocess.run([
compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', '-DARDUINO',
'-DESP32', '-DARDUINO_USB_MODE=0', '-DARDUINO_USB_CDC_ON_BOOT=1',
*sanitizer_flags, '-I' + str(MOCKS), '-I' + str(ROOT / 'src'),
str(source), str(ROOT / 'src' / 'helpers' / 'UsbLoggingClientActivity.cpp'),
'-o', str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stderr)
tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(tested.returncode, 0, tested.stderr)
self.assertIn('USB stats client activity lease passed', tested.stdout)
if __name__ == '__main__':
unittest.main()
+501
View File
@@ -0,0 +1,501 @@
"""Compile the real USB watchdog policy and exercise its bounded timelines."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <initializer_list>
#include <limits>
#include <helpers/UsbLoggingWatchdogPolicy.h>
using Policy = mesh::UsbLoggingWatchdogPolicy;
using Action = Policy::Action;
using Observation = mesh::UsbLoggingObservation;
using AutoArm = mesh::UsbLoggingAutoArmPolicy;
struct Clock {
uint64_t elapsed = 0;
uint32_t initial = 0;
explicit Clock(uint32_t first = 0) : initial(first) {}
uint32_t now() const { return uint32_t(uint64_t(initial) + elapsed); }
void advance(uint64_t duration) { elapsed += duration; }
};
Observation missing() { return {true, false, false, false, 0}; }
Observation idle() { return {true, true, true, false, 0}; }
Observation pending() { return {true, true, true, true, 0}; }
static void check_intervals_and_corruption() {
const unsigned hours[] = {1, 2, 4, 8, 16, 32, 64, 128, 168};
for (uint8_t step = 0; step <= Policy::MAX_STEP; ++step) {
assert(Policy::intervalMs(step) == hours[step] * Policy::BASE_MS);
assert(Policy::nextStep(step) == (step == 8 ? 8 : step + 1));
Policy policy;
policy.begin(0, step);
assert(policy.update(0, true, missing()) == Action::None);
const auto timeout = Policy::intervalMs(step);
assert(policy.update(Policy::EARLY_RECOVERY_MS - 1, true, missing()) == Action::None);
assert(policy.update(Policy::EARLY_RECOVERY_MS, true, missing()) == Action::SoftRecovery);
policy.actionAttempted(Action::SoftRecovery);
assert(policy.update(Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS - 1,
true, missing()) == Action::None);
assert(policy.update(Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS,
true, missing()) == Action::Reenumerate);
policy.actionAttempted(Action::Reenumerate);
assert(policy.update(timeout - 1, true, missing()) == Action::None);
assert(policy.update(timeout, true, missing()) == Action::Reboot);
assert(policy.backoffStep() == step); // An action never persists a tier.
assert(policy.inactiveSeconds() == timeout / 1000);
}
for (uint8_t corrupt : {uint8_t(9), uint8_t(127), uint8_t(255)}) {
Policy policy;
policy.begin(0, corrupt);
assert(policy.backoffStep() == 8);
assert(Policy::intervalMs(corrupt) == Policy::MAX_MS);
assert(Policy::nextStep(corrupt) == 8);
assert(policy.update(0, true, missing()) == Action::None);
assert(policy.update(Policy::EARLY_RECOVERY_MS - 1, true, missing()) == Action::None);
assert(policy.update(Policy::MAX_MS - 1, true, missing()) == Action::SoftRecovery);
assert(policy.update(Policy::MAX_MS, true, missing()) == Action::SoftRecovery);
policy.backoffCommitted(corrupt);
assert(policy.backoffStep() == 8);
}
}
static void check_missing_unopened_and_unplugged() {
for (const auto observation : {missing(), Observation{true, true, false, false, 0},
Observation{true, false, true, false, 0}}) {
Policy policy;
policy.begin(500, 0);
assert(policy.update(500, true, observation) == Action::None);
for (uint32_t elapsed = 100; elapsed < Policy::BASE_MS; elapsed += 100) {
assert(policy.update(500 + elapsed, true, observation)
== (elapsed < Policy::EARLY_RECOVERY_MS ? Action::None : Action::SoftRecovery));
}
assert(policy.update(500 + Policy::BASE_MS, true, observation) == Action::SoftRecovery);
assert(policy.inactiveSeconds() == 3600);
}
Policy policy;
policy.begin(0, 0);
assert(policy.update(0, true, idle()) == Action::None);
auto unplugged = missing();
assert(policy.update(1000, true, unplugged) == Action::None);
assert(policy.update(1000 + Policy::EARLY_RECOVERY_MS - 1, true, unplugged) == Action::None);
assert(policy.update(1000 + Policy::BASE_MS, true, unplugged) == Action::SoftRecovery);
policy.actionAttempted(Action::SoftRecovery);
assert(policy.update(1001 + Policy::BASE_MS, true, idle()) == Action::None);
assert(policy.stage() == 0 && policy.inactiveSeconds() == 0);
}
static void check_idle_and_pending_stall() {
Policy policy;
policy.begin(0, 0);
for (uint32_t day = 0; day <= 80; ++day) {
assert(policy.update(day * 86400000U, true, idle()) == Action::None);
assert(!policy.stalled() && policy.inactiveSeconds() == 0);
}
Clock clock;
Policy stalled;
stalled.begin(clock.now(), 0);
auto observation = pending();
assert(stalled.update(clock.now(), true, observation) == Action::None);
clock.advance(Policy::STALL_OBSERVE_MS - 1);
assert(stalled.update(clock.now(), true, observation) == Action::None);
assert(!stalled.stalled());
clock.advance(1);
assert(stalled.update(clock.now(), true, observation) == Action::None);
assert(stalled.stalled() && stalled.inactiveSeconds() == 30);
clock.advance(Policy::EARLY_RECOVERY_MS - Policy::STALL_OBSERVE_MS - 1);
assert(stalled.update(clock.now(), true, observation) == Action::None);
clock.advance(1);
assert(stalled.update(clock.now(), true, observation) == Action::SoftRecovery);
stalled.actionAttempted(Action::SoftRecovery);
assert(stalled.stage() == 1 && stalled.stalled());
// Destructive purge can remove demand, but cannot fake completion evidence.
observation.pending = false;
clock.advance(1);
assert(stalled.update(clock.now(), true, observation) == Action::None);
assert(stalled.stalled() && stalled.stage() == 1);
clock.advance(Policy::RECOVERY_GRACE_MS - 1);
assert(stalled.update(clock.now(), true, observation) == Action::Reenumerate);
stalled.actionAttempted(Action::Reenumerate);
clock.advance(Policy::RECOVERY_GRACE_MS - 1);
assert(stalled.update(clock.now(), true, observation) == Action::None);
clock.advance(1);
assert(stalled.update(clock.now(), true, observation) == Action::None);
clock.advance(Policy::BASE_MS - clock.elapsed - 1);
assert(stalled.update(clock.now(), true, observation) == Action::None);
clock.advance(1);
assert(stalled.update(clock.now(), true, observation) == Action::Reboot);
assert(stalled.backoffStep() == 0); // Caller still must persist nextStep().
}
static void check_stages_and_deferred_retries() {
Policy policy;
Clock clock;
auto observation = missing();
policy.begin(clock.now(), 0);
assert(policy.update(clock.now(), true, observation) == Action::None);
clock.advance(Policy::EARLY_RECOVERY_MS);
assert(policy.update(clock.now(), true, observation, false) == Action::None);
assert(policy.deferred() && policy.stage() == 0);
clock.advance(1000);
assert(policy.update(clock.now(), true, observation, false) == Action::None);
assert(policy.update(clock.now(), true, observation, true) == Action::SoftRecovery);
// Backend Deferred: leave action unnoted and retry it without timer extension.
clock.advance(1);
assert(policy.update(clock.now(), true, observation) == Action::SoftRecovery);
policy.actionAttempted(Action::SoftRecovery);
assert(policy.stage() == 1);
clock.advance(Policy::RECOVERY_GRACE_MS - 1);
assert(policy.update(clock.now(), true, observation) == Action::None);
clock.advance(1);
assert(policy.update(clock.now(), true, observation, false) == Action::None);
assert(policy.deferred() && policy.stage() == 1);
assert(policy.update(clock.now(), true, observation) == Action::Reenumerate);
clock.advance(1);
assert(policy.update(clock.now(), true, observation) == Action::Reenumerate);
policy.actionAttempted(Action::Reenumerate);
assert(policy.stage() == 2);
clock.advance(Policy::RECOVERY_GRACE_MS - 1);
assert(policy.update(clock.now(), true, observation) == Action::None);
clock.advance(1);
assert(policy.update(clock.now(), true, observation, false) == Action::None);
assert(policy.deferred() && policy.backoffStep() == 0);
assert(policy.update(clock.now(), true, observation) == Action::None);
clock.advance(Policy::BASE_MS - clock.elapsed);
assert(policy.update(clock.now(), true, observation) == Action::Reboot);
clock.advance(1);
assert(policy.update(clock.now(), true, observation) == Action::Reboot);
policy.backoffCommitted(Policy::nextStep(policy.backoffStep()));
assert(policy.backoffStep() == 1);
policy.actionAttempted(Action::Reboot);
assert(policy.stage() == 3);
}
static void check_progress_aborts_and_counter_wrap() {
for (unsigned stage = 1; stage <= 2; ++stage) {
Policy policy;
Clock clock;
auto observation = pending();
policy.begin(clock.now(), 0);
assert(policy.update(clock.now(), true, observation) == Action::None);
clock.advance(Policy::BASE_MS);
assert(policy.update(clock.now(), true, observation) == Action::SoftRecovery);
policy.actionAttempted(Action::SoftRecovery);
if (stage == 2) {
clock.advance(Policy::RECOVERY_GRACE_MS);
assert(policy.update(clock.now(), true, observation) == Action::Reenumerate);
policy.actionAttempted(Action::Reenumerate);
}
observation.pending = false;
clock.advance(1);
assert(policy.update(clock.now(), true, observation) == Action::None);
assert(policy.stage() == stage && policy.stalled());
++observation.tx_progress; // Only the backend's actual ACK advances this.
assert(policy.update(clock.now(), true, observation, false) == Action::None);
assert(policy.stage() == 0 && !policy.stalled() && policy.inactiveSeconds() == 0);
}
Policy policy;
auto observation = pending();
observation.tx_progress = UINT32_MAX;
policy.begin(0, 0);
policy.update(0, true, observation);
policy.update(Policy::STALL_OBSERVE_MS, true, observation);
assert(policy.stalled());
observation.tx_progress = 0;
assert(policy.update(Policy::STALL_OBSERVE_MS + 1, true, observation) == Action::None);
assert(!policy.stalled() && policy.inactiveSeconds() == 0);
assert(policy.update(Policy::STALL_OBSERVE_MS * 2, true, observation) == Action::None);
assert(!policy.stalled());
}
static void check_healthy_reset_requires_continuity_and_commit() {
Policy policy;
policy.begin(0, 4);
assert(policy.update(0, true, idle()) == Action::None);
assert(policy.update(Policy::HEALTHY_RESET_MS - 1, true, idle()) == Action::None);
assert(policy.update(Policy::HEALTHY_RESET_MS, true, idle(), false) == Action::None);
assert(policy.deferred() && policy.backoffStep() == 4);
assert(policy.update(Policy::HEALTHY_RESET_MS, true, idle()) == Action::ResetBackoff);
assert(policy.backoffStep() == 4); // Persistence not acknowledged yet.
assert(policy.update(Policy::HEALTHY_RESET_MS + 1, true, idle()) == Action::ResetBackoff);
policy.backoffCommitted(0);
assert(policy.update(Policy::HEALTHY_RESET_MS + 2, true, idle()) == Action::None);
policy.begin(0, 7);
assert(policy.update(0, true, idle()) == Action::None);
assert(policy.update(300000, true, missing()) == Action::None);
assert(policy.update(400000, true, idle()) == Action::None);
assert(policy.update(400000 + Policy::HEALTHY_RESET_MS - 1, true, idle()) == Action::None);
assert(policy.update(400000 + Policy::HEALTHY_RESET_MS, true, idle()) == Action::ResetBackoff);
}
static void check_disabled_unsupported_and_reenable() {
Policy policy;
policy.begin(0, 2);
assert(policy.update(0, true, missing()) == Action::None);
assert(policy.update(Policy::BASE_MS * 2, false, missing()) == Action::None);
assert(policy.inactiveSeconds() == 0 && policy.stage() == 0);
const auto start = Policy::BASE_MS * 2 + 1;
assert(policy.update(start, true, missing()) == Action::None);
assert(policy.update(start + Policy::EARLY_RECOVERY_MS - 1, true, missing()) == Action::None);
assert(policy.update(start + Policy::EARLY_RECOVERY_MS, true, missing()) == Action::SoftRecovery);
assert(policy.backoffStep() == 2);
policy.actionAttempted(Action::SoftRecovery);
assert(policy.update(start + Policy::BASE_MS * 4 + 1, false, missing()) == Action::None);
assert(policy.stage() == 0 && !policy.stalled() && !policy.deferred());
Observation unsupported;
policy.begin(0, 0);
assert(policy.update(0, true, unsupported) == Action::None);
assert(policy.update(Policy::MAX_MS, true, unsupported) == Action::None);
assert(policy.inactiveSeconds() == 0);
assert(policy.update(Policy::MAX_MS + 1, true, missing()) == Action::None);
assert(policy.update(Policy::MAX_MS + Policy::EARLY_RECOVERY_MS, true, missing()) == Action::None);
assert(policy.update(Policy::MAX_MS + Policy::EARLY_RECOVERY_MS + 1, true, missing()) == Action::SoftRecovery);
}
static void check_millis_wrap_long_run_and_old_samples() {
Policy policy;
Clock clock(UINT32_MAX - 1500);
policy.begin(clock.now(), 8);
assert(policy.update(clock.now(), true, missing()) == Action::None);
for (unsigned day = 1; day <= 150; ++day) {
clock.advance(86400000UL);
const auto action = policy.update(clock.now(), true, missing());
assert(action == Action::SoftRecovery);
assert(policy.inactiveSeconds() == day * 86400U);
}
// Status saturates instead of rolling over after 136 years of elapsed time.
for (unsigned day = 151; day <= 49712; ++day) {
clock.advance(86400000UL);
assert(policy.update(clock.now(), true, missing()) == Action::SoftRecovery);
}
assert(policy.inactiveSeconds() == UINT32_MAX);
policy.begin(1000, 0);
policy.update(1000, true, missing());
policy.update(1100, true, missing());
policy.update(1090, true, missing()); // Brief out-of-order sample is ignored.
assert(policy.inactiveSeconds() == 0);
assert(policy.update(1000 + Policy::EARLY_RECOVERY_MS - 1, true, missing()) == Action::None);
assert(policy.update(1000 + Policy::EARLY_RECOVERY_MS, true, missing()) == Action::SoftRecovery);
}
static void check_pending_resets_after_real_progress() {
Policy policy;
auto observation = pending();
policy.begin(0, 0);
policy.update(0, true, observation);
assert(policy.update(Policy::EARLY_RECOVERY_MS - 1, true, observation) == Action::None);
assert(policy.stalled());
observation.tx_progress = 1;
assert(policy.update(Policy::EARLY_RECOVERY_MS - 1, true, observation) == Action::None);
assert(!policy.stalled());
assert(policy.update(Policy::EARLY_RECOVERY_MS * 2 - 2, true, observation) == Action::None);
assert(policy.update(Policy::EARLY_RECOVERY_MS * 2 - 1, true, observation) == Action::SoftRecovery);
}
static void check_pre_recovery_purge_cannot_erase_a_stall() {
Policy policy;
auto observation = pending();
policy.begin(0, 0);
assert(policy.update(0, true, observation) == Action::None);
assert(policy.update(Policy::STALL_OBSERVE_MS, true, observation) == Action::None);
assert(policy.stalled());
observation.pending = false; // An external queue/session cleanup, not ACK.
assert(policy.update(Policy::STALL_OBSERVE_MS + 1, true, observation) == Action::None);
assert(policy.stalled() && policy.inactiveSeconds() == 30);
assert(policy.update(Policy::BASE_MS, true, observation) == Action::SoftRecovery);
policy.actionAttempted(Action::SoftRecovery);
assert(policy.update(Policy::BASE_MS + 1, true, observation) == Action::None);
assert(policy.stalled() && policy.stage() == 1);
++observation.tx_progress;
assert(policy.update(Policy::BASE_MS + 2, true, observation) == Action::None);
assert(!policy.stalled() && policy.stage() == 0);
}
static void check_auto_qualification_is_continuous() {
AutoArm policy;
policy.begin(100);
assert(!policy.update(100, true, true));
assert(policy.connectedSeconds() == 0);
assert(!policy.update(100 + AutoArm::QUALIFICATION_MS - 1, true, true));
assert(policy.connectedSeconds() == AutoArm::QUALIFICATION_MS / 1000 - 1);
assert(policy.update(100 + AutoArm::QUALIFICATION_MS, true, true));
assert(policy.connectedSeconds() == 14U * 86400U);
// Disconnect, DTR closure, observed stall, disabled master, unsupported
// transport and non-durable storage all map to qualifying=false. None can
// accumulate separate cable sessions into the required continuous period.
for (unsigned reason = 0; reason != 6; ++reason) {
policy.begin(0);
assert(!policy.update(0, true, true));
assert(!policy.update(AutoArm::QUALIFICATION_MS - 1, true, true));
assert(!policy.update(AutoArm::QUALIFICATION_MS, false, true));
assert(policy.connectedSeconds() == 0);
const uint32_t restarted = AutoArm::QUALIFICATION_MS + 1;
assert(!policy.update(restarted, true, true));
assert(!policy.update(restarted + AutoArm::QUALIFICATION_MS - 1, true, true));
assert(policy.update(restarted + AutoArm::QUALIFICATION_MS, true, true));
}
policy.begin(0);
assert(!policy.update(0, true, true));
assert(!policy.update(AutoArm::QUALIFICATION_MS - 1, true, true));
assert(!policy.update(AutoArm::QUALIFICATION_MS, true, false));
assert(policy.connectedSeconds() == 0);
assert(!policy.update(AutoArm::QUALIFICATION_MS + 1, true, true));
assert(!policy.update(AutoArm::QUALIFICATION_MS * 2, true, true));
assert(policy.update(AutoArm::QUALIFICATION_MS * 2 + 1, true, true));
// A reboot always loses the unprovable connected interval.
policy.begin(AutoArm::QUALIFICATION_MS * 2 + 1);
assert(!policy.update(AutoArm::QUALIFICATION_MS * 2 + 1, true, true));
assert(policy.connectedSeconds() == 0);
}
static void check_auto_clock_wrap_and_saturation() {
AutoArm policy;
Clock clock(UINT32_MAX - 1000);
policy.begin(clock.now());
assert(!policy.update(clock.now(), true, true));
for (unsigned day = 1; day <= 150; ++day) {
clock.advance(86400000UL);
assert(policy.update(clock.now(), true, true) == (day >= 14));
assert(policy.connectedSeconds() == day * 86400U);
}
for (unsigned day = 151; day <= 49712; ++day) {
clock.advance(86400000UL);
assert(policy.update(clock.now(), true, true));
}
assert(policy.connectedSeconds() == UINT32_MAX);
assert(!policy.update(clock.now(), false, true));
assert(policy.connectedSeconds() == 0);
policy.begin(1000);
assert(!policy.update(1000, true, true));
assert(!policy.update(1100, true, true));
assert(!policy.update(1090, true, true));
assert(policy.connectedSeconds() == 0);
assert(!policy.update(1000 + AutoArm::QUALIFICATION_MS - 1, true, true));
assert(policy.update(1000 + AutoArm::QUALIFICATION_MS, true, true));
}
static void check_auto_stall_observation_does_not_arm_recovery() {
Policy policy;
auto observation = pending();
policy.begin(0, 0);
assert(policy.update(0, false, observation) == Action::None);
assert(policy.update(Policy::STALL_OBSERVE_MS, false, observation) == Action::None);
assert(policy.stalled() && policy.inactiveSeconds() == 0);
assert(policy.update(Policy::BASE_MS * 20, false, observation) == Action::None);
assert(policy.stalled() && policy.stage() == 0);
// Promotion to On starts a new complete interval, never inherits Auto age.
const uint32_t activation = Policy::BASE_MS * 20 + 1;
assert(policy.update(activation, true, observation) == Action::None);
assert(policy.update(activation + Policy::EARLY_RECOVERY_MS - 1, true, observation) == Action::None);
assert(policy.update(activation + Policy::EARLY_RECOVERY_MS, true, observation) == Action::SoftRecovery);
}
static void check_auto_usb_only_recovery_vetoes_board_reset() {
Policy policy;
policy.begin(0, 0);
auto observation = missing();
assert(policy.update(0, true, observation, true, false) == Action::None);
assert(policy.update(Policy::EARLY_RECOVERY_MS, true, observation, false, false) == Action::None);
assert(policy.deferred());
assert(policy.update(Policy::EARLY_RECOVERY_MS, true, observation, true, false) == Action::SoftRecovery);
policy.actionAttempted(Action::SoftRecovery);
const uint32_t reenum = Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS;
assert(policy.update(reenum, true, observation, true, false) == Action::Reenumerate);
policy.actionAttempted(Action::Reenumerate);
for (uint32_t day = 1; day <= 80; ++day) {
assert(policy.update(day * 86400000U, true, observation, true, false) == Action::None);
assert(policy.stage() == 2 && policy.backoffStep() == 0);
}
assert(policy.update(80U * 86400000U, true, observation, false, true) == Action::None);
assert(policy.deferred());
assert(policy.update(80U * 86400000U, true, observation, true, true) == Action::Reboot);
}
static void check_ack_without_effective_reader_cannot_extend_outage() {
for (auto observation : {Observation{true, true, false, false, 0},
Observation{true, false, true, false, 0}}) {
Policy policy;
policy.begin(0, 0);
assert(policy.update(0, true, observation) == Action::None);
for (uint32_t elapsed = 10000; elapsed < Policy::EARLY_RECOVERY_MS; elapsed += 10000) {
++observation.tx_progress;
assert(policy.update(elapsed, true, observation) == Action::None);
}
++observation.tx_progress;
assert(policy.update(Policy::EARLY_RECOVERY_MS, true, observation) == Action::SoftRecovery);
assert(policy.inactiveSeconds() == 300);
policy.actionAttempted(Action::SoftRecovery);
++observation.tx_progress;
const auto reenum = Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS;
assert(policy.update(reenum, true, observation) == Action::Reenumerate);
policy.actionAttempted(Action::Reenumerate);
++observation.tx_progress;
assert(policy.update(Policy::BASE_MS - 1, true, observation) == Action::None);
++observation.tx_progress;
assert(policy.update(Policy::BASE_MS, true, observation) == Action::Reboot);
}
}
int main() {
check_intervals_and_corruption();
check_missing_unopened_and_unplugged();
check_idle_and_pending_stall();
check_stages_and_deferred_retries();
check_progress_aborts_and_counter_wrap();
check_healthy_reset_requires_continuity_and_commit();
check_disabled_unsupported_and_reenable();
check_millis_wrap_long_run_and_old_samples();
check_pending_resets_after_real_progress();
check_pre_recovery_purge_cannot_erase_a_stall();
check_auto_qualification_is_continuous();
check_auto_clock_wrap_and_saturation();
check_auto_stall_observation_does_not_arm_recovery();
check_auto_usb_only_recovery_vetoes_board_reset();
check_ack_without_effective_reader_cannot_extend_outage();
puts("USB watchdog policy timelines passed");
}
'''
class UsbLoggingWatchdogTest(unittest.TestCase):
def test_production_policy_timelines(self):
compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')
if compiler is None:
self.skipTest('a host C++17 compiler is required')
sanitizer_flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
with tempfile.TemporaryDirectory(prefix='usb-watchdog-policy-') as directory:
work = Path(directory)
cpp = work / 'test.cpp'
binary = work / ('test.exe' if os.name == 'nt' else 'test')
cpp.write_text(HARNESS, encoding='utf-8')
built = subprocess.run([
compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror',
*sanitizer_flags, '-I' + str(ROOT / 'src'),
str(cpp), '-o', str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stderr)
tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(tested.returncode, 0, tested.stderr)
self.assertIn('USB watchdog policy timelines passed', tested.stdout)
if __name__ == '__main__':
unittest.main()
+287
View File
@@ -0,0 +1,287 @@
"""Execute the actual role ownership vetoes for USB watchdog recovery."""
from itertools import product
from pathlib import Path
import os
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
COMMON_HARNESS = r'''
#include <cassert>
#include <cstddef>
#include <cstdint>
#include <initializer_list>
struct Board {
bool updating=false, testing=false;
bool isOTAUpdateRunning() const{return updating;}
bool isRadioTestActive() const{return testing;}
} board;
struct RadioDriver {
bool observing=false, calibrating=false;
bool isWatchdogObserving() const{return observing;}
bool isCalibratingNoiseFloor() const{return calibrating;}
} radio_driver;
namespace mesh {
struct Status {bool reader_connected=true, stalled=false;};
static Status status;
Status usbLoggingStatus(){return status;}
}
struct Store {
bool dirty=false;
bool hasPendingContactWrites() const{return dirty;}
};
struct SerialInterface {
bool pending=false, connected=false;
bool hasPendingIO() const{return pending;}
bool isConnected() const{return connected;}
};
struct RoleMesh {
uint32_t dirty_contacts_expiry=0, _ota_update_at=0, set_radio_at=0,
_scheduled_reboot_at=0;
bool outbound=false, temporary=false, ota_apply=false,
saved_radio_apply_pending=false, temp_radio_handoff_pending=false,
command_radio_apply_pending=false, binary_trace_pending=false,
_iter_started=false, terminal=true;
void* pending_serial_reply_route=nullptr;
void* sign_data=nullptr;
Store store;
Store* _store=&store;
SerialInterface serial;
SerialInterface* _serial=&serial;
bool hasOutbound() const{return outbound;}
bool isAnyTempRadioActive() const{return temporary;}
bool hasPendingOtaApply() const{return ota_apply;}
bool isTerminalMode() const{return terminal;}
#if MESH_USB_CONSOLE_COOPERATIVE
bool functional_output=false;
bool hasPendingSerialOutput() const{return functional_output;}
#endif
@ACCESSOR_DECLARATION@
} the_mesh;
@ACCESSOR_DEFINITION@
static char command[32]={};
static bool command_overflow=false;
#if defined(ENABLE_USB_INTERFACE)
static SerialInterface usb_serial_interface;
static bool usb_connected=false;
static bool usb_mota_mode=false;
static unsigned usb_terminal_line_len=0;
static bool usb_terminal_discard_line=false;
bool isUsbTerminalDataConnected(){return usb_connected;}
#endif
@CALLBACK@
void reset(){
board=Board{};radio_driver=RadioDriver{};mesh::status=mesh::Status{};
the_mesh=RoleMesh{};
// Assignment copies the fixture's dependency pointers; point them back at
// the live fixture objects, not the temporary used to reset scalar state.
the_mesh._store=&the_mesh.store;the_mesh._serial=&the_mesh.serial;
command[0]=0;command_overflow=false;
#if defined(ENABLE_USB_INTERFACE)
usb_serial_interface=SerialInterface{};usb_connected=false;
usb_mota_mode=false;usb_terminal_line_len=0;usb_terminal_discard_line=false;
#endif
}
int main(){
reset();assert(the_mesh.canRecoverUsbLogging());
assert(usbLoggingRecoverySafe(nullptr));
// Each protection must survive a physically absent/stalled reader. That
// exception is only for stale ASCII work, never radio, flash or protocol.
for(unsigned state=0;state<3;++state){
reset();mesh::status.reader_connected=state!=1;
mesh::status.stalled=state==2;
board.updating=true;assert(!usbLoggingRecoverySafe(nullptr));board.updating=false;
board.testing=true;assert(!usbLoggingRecoverySafe(nullptr));board.testing=false;
radio_driver.observing=true;assert(!usbLoggingRecoverySafe(nullptr));
radio_driver.observing=false;
radio_driver.calibrating=true;assert(!usbLoggingRecoverySafe(nullptr));
radio_driver.calibrating=false;
for(auto member : {&RoleMesh::outbound,&RoleMesh::temporary,&RoleMesh::ota_apply,
&RoleMesh::saved_radio_apply_pending}){
the_mesh.*member=true;
assert(!the_mesh.canRecoverUsbLogging());
assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.*member=false;
}
the_mesh.dirty_contacts_expiry=1;
assert(!the_mesh.canRecoverUsbLogging());assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.dirty_contacts_expiry=0;
@ROLE_VETOES@
}
@COMMAND_VETOES@
@COMPANION_VETOES@
reset();assert(usbLoggingRecoverySafe(nullptr));
}
'''
class UsbLoggingWatchdogRoleTests(unittest.TestCase):
def test_actual_ordinary_role_callbacks_with_and_without_native_console(self):
for role, header, specific in (
('simple_repeater', 'MyMesh.h', '''
the_mesh.temp_radio_handoff_pending=true;
assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.temp_radio_handoff_pending=false;
the_mesh._ota_update_at=1;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh._ota_update_at=0;
'''),
('simple_room_server', 'MyMesh.h', '''
the_mesh.set_radio_at=1;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.set_radio_at=0;
the_mesh._ota_update_at=1;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh._ota_update_at=0;
'''),
('simple_sensor', 'SensorMesh.h', '''
the_mesh.set_radio_at=1;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.set_radio_at=0;
''')):
directory = ROOT / 'examples' / role
accessor = method((directory / header).read_text(),
'bool canRecoverUsbLogging() const')
callback = method((directory / 'main.cpp').read_text(),
'static bool usbLoggingRecoverySafe(void*)')
command_checks = '''
reset();command[0]='x';assert(!usbLoggingRecoverySafe(nullptr));
mesh::status.reader_connected=false;assert(usbLoggingRecoverySafe(nullptr));
mesh::status.reader_connected=true;mesh::status.stalled=true;
assert(usbLoggingRecoverySafe(nullptr));
reset();
'''
if role == 'simple_repeater':
command_checks += '''
command_overflow=true;assert(!usbLoggingRecoverySafe(nullptr));
mesh::status.reader_connected=false;assert(usbLoggingRecoverySafe(nullptr));
reset();
'''
if role != 'simple_sensor':
command_checks += '''
#if MESH_USB_CONSOLE_COOPERATIVE
the_mesh.functional_output=true;assert(!usbLoggingRecoverySafe(nullptr));
mesh::status.reader_connected=false;assert(usbLoggingRecoverySafe(nullptr));
mesh::status.reader_connected=true;mesh::status.stalled=true;
assert(usbLoggingRecoverySafe(nullptr));
reset();
#endif
'''
harness = self._harness(accessor, '', callback, specific,
command_checks, '')
for cooperative in (0, 1):
with self.subTest(role=role, cooperative=cooperative):
self._compile_and_run(harness, cooperative=cooperative)
def test_actual_companion_protocol_flash_radio_and_command_ownership(self):
directory = ROOT / 'examples/companion_radio'
accessor = method((directory / 'MyMesh.cpp').read_text(),
'bool MyMesh::canRecoverUsbLogging() const')
accessor = accessor.replace('MyMesh::', 'RoleMesh::', 1)
callback = method((directory / 'main.cpp').read_text(),
'static bool usbLoggingRecoverySafe(void*)')
role_checks = r'''
for(auto member : {&RoleMesh::command_radio_apply_pending,
&RoleMesh::binary_trace_pending}){
the_mesh.*member=true;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.*member=false;
}
the_mesh.store.dirty=true;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.store.dirty=false;
the_mesh._scheduled_reboot_at=1;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh._scheduled_reboot_at=0;
int route=1;
the_mesh.pending_serial_reply_route=&route;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.pending_serial_reply_route=nullptr;
the_mesh.sign_data=&route;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.sign_data=nullptr;
the_mesh.serial.pending=true;assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.serial.pending=false;
the_mesh._iter_started=true;the_mesh.serial.connected=true;
assert(!usbLoggingRecoverySafe(nullptr));
the_mesh.serial.connected=false;assert(usbLoggingRecoverySafe(nullptr));
the_mesh._iter_started=false;
the_mesh._serial=nullptr;assert(usbLoggingRecoverySafe(nullptr));
the_mesh._serial=&the_mesh.serial;
'''
companion_checks = r'''
#if defined(ENABLE_USB_INTERFACE)
// A live binary client or buffered protocol transaction is always protected,
// even if the dedicated logging CDC is disconnected or its output is stuck.
for(unsigned state=0;state<3;++state){
reset();mesh::status.reader_connected=state!=1;mesh::status.stalled=state==2;
the_mesh.terminal=false;usb_connected=true;
assert(!usbLoggingRecoverySafe(nullptr));
usb_connected=false;assert(usbLoggingRecoverySafe(nullptr));
usb_serial_interface.pending=true;assert(!usbLoggingRecoverySafe(nullptr));
usb_serial_interface.pending=false;
#if COMPANION_FEATURE_USB_MOTA_SOURCE
usb_mota_mode=true;assert(!usbLoggingRecoverySafe(nullptr));
usb_mota_mode=false;
#endif
}
reset();usb_terminal_line_len=1;assert(!usbLoggingRecoverySafe(nullptr));
mesh::status.reader_connected=false;assert(usbLoggingRecoverySafe(nullptr));
mesh::status.reader_connected=true;mesh::status.stalled=true;
assert(usbLoggingRecoverySafe(nullptr));
reset();usb_terminal_discard_line=true;assert(!usbLoggingRecoverySafe(nullptr));
mesh::status.reader_connected=false;assert(usbLoggingRecoverySafe(nullptr));
mesh::status.reader_connected=true;mesh::status.stalled=true;
assert(usbLoggingRecoverySafe(nullptr));
#endif
'''
harness = self._harness('bool canRecoverUsbLogging() const;', accessor,
callback, role_checks, '', companion_checks)
for usb, mota in product((0, 1), repeat=2):
with self.subTest(usb=usb, mota=mota):
self._compile_and_run(harness, cooperative=usb, usb=usb, mota=mota)
def test_every_role_loads_durable_state_and_services_recovery_in_normal_loop(self):
for role in ('companion_radio', 'simple_repeater', 'simple_room_server',
'simple_sensor'):
with self.subTest(role=role):
source = (ROOT / 'examples' / role / 'main.cpp').read_text()
setup = method(source, 'void setup()')
loop = method(source[source.rindex('\nvoid loop()'):], 'void loop()')
self.assertIn('loadUsbLoggingWatchdog(', setup)
self.assertTrue('!volatile_primary_fs' in setup or
'!store.isVolatilePrimaryFS()' in setup)
self.assertEqual(setup.count('[]() -> uint32_t { return rtc_clock.getCurrentTime(); }'), 2)
self.assertNotIn('getCurrentTimeUnique()', setup)
self.assertIn('mesh::serviceUsbLoggingPort();', loop)
self.assertIn('if (mesh::serviceUsbLoggingWatchdog(usbLoggingRecoverySafe)) '
'board.reboot();', loop)
self.assertIn('!mesh::isUsbLoggingWatchdogArmed()', source)
@staticmethod
def _harness(declaration, definition, callback, role, commands, companion):
return (COMMON_HARNESS.replace('@ACCESSOR_DECLARATION@', declaration)
.replace('@ACCESSOR_DEFINITION@', definition)
.replace('@CALLBACK@', callback).replace('@ROLE_VETOES@', role)
.replace('@COMMAND_VETOES@', commands)
.replace('@COMPANION_VETOES@', companion))
def _compile_and_run(self, harness, cooperative, usb=0, mota=0):
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
cpp = work / 'roles.cpp'
cpp.write_text(harness)
binary = work / 'roles'
flags = ['-DMESH_USB_CONSOLE_COOPERATIVE=' + str(cooperative),
'-DCOMPANION_FEATURE_USB_MOTA_SOURCE=' + str(mota)]
if usb:
flags.append('-DENABLE_USB_INTERFACE=1')
built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17',
'-Wall', '-Wextra', '-Werror', '-Wno-unused-variable',
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie', *flags, str(cpp), '-o', str(binary)],
capture_output=True, text=True)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
ran = subprocess.run([str(binary)], capture_output=True, text=True)
self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr)
if __name__ == '__main__':
unittest.main()
+99
View File
@@ -0,0 +1,99 @@
"""Compile the actual watchdog runtime with isolated filesystem/USB boundaries."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / 'test' / 'fixtures' / 'usb_logging_watchdog'
class UsbLoggingWatchdogRuntimeTest(unittest.TestCase):
def test_production_runtime_and_persistence(self):
compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')
if compiler is None:
self.skipTest('a host C++17 compiler is required')
sanitizer_flags = [] if os.name == 'nt' else [
'-fsanitize=address,undefined', '-fno-sanitize-recover=all',
'-fno-pie', '-no-pie']
with tempfile.TemporaryDirectory(prefix='usb-watchdog-runtime-') as directory:
source = ROOT / 'src' / 'helpers' / 'UsbLoggingWatchdog.cpp'
for ota in (False, True):
with self.subTest(ota_enabled=ota):
binary = Path(directory) / ('ota' + str(ota) + '.exe')
compile_source = source
if ota:
# Copy this translation unit byte-for-byte. Its quoted
# OTA include can then use an isolated dependency fake,
# while every tested production statement is unchanged.
compile_source = Path(directory) / 'UsbLoggingWatchdog.cpp'
compile_source.write_bytes(source.read_bytes())
built = subprocess.run([
compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', '-pthread',
*sanitizer_flags, '-DARDUINO', '-DESP32', '-DESP32_PLATFORM',
'-DARDUINO_USB_MODE=0', '-DARDUINO_USB_CDC_ON_BOOT=1',
*(['-DENABLE_OTA'] if ota else []),
'-I' + str(FIXTURE / 'mocks'),
'-I' + str(ROOT / 'src' / 'helpers'), '-I' + str(ROOT / 'src'),
str(compile_source), str(FIXTURE / 'test_usb_logging_watchdog_runtime.cpp'),
'-o', str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stderr)
tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(tested.returncode, 0, tested.stderr)
self.assertIn('USB watchdog runtime and persistence passed', tested.stdout)
def test_unsupported_transport_has_no_recovery_or_storage_work(self):
compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')
if compiler is None:
self.skipTest('a host C++17 compiler is required')
with tempfile.TemporaryDirectory(prefix='usb-watchdog-unsupported-') as directory:
harness = Path(directory) / 'unsupported.cpp'
harness.write_text(r'''
#include <cassert>
#include <cstring>
#include <helpers/UsbLoggingWatchdog.h>
#include <helpers/UsbLoggingClientActivity.h>
namespace mesh { bool isUsbLoggingEnabled() { return true; } }
static bool safe(void*) { assert(false); return false; }
int main() {
mesh::loadUsbLoggingWatchdog(nullptr, true);
const auto status = mesh::usbLoggingStatus();
assert(status.logging_enabled && !status.supported && !status.watchdog_auto);
assert(!status.logger_active && !status.persistence_ready);
assert(!mesh::serviceUsbLoggingWatchdog(safe));
assert(!mesh::isUsbLoggingWatchdogArmed());
assert(!mesh::isUsbLoggingWatchdogUpdateActive());
mesh::noteUsbLoggingStatsCommand("stats-core");
assert(!mesh::isUsbLoggingClientActive());
mesh::clearUsbLoggingClientActivity();
char reply[160] = {};
for (const char* command : {"get usb.watchdog", "get usb.watchdog.last", "set usb.watchdog auto",
"set usb.watchdog on", "set usb.watchdog off"}) {
assert(mesh::handleUsbLoggingWatchdogCommand(command, reply, sizeof(reply)));
assert(strstr(reply, "unsupported"));
}
assert(!mesh::handleUsbLoggingWatchdogCommand("get usb.watchdogx", reply, sizeof(reply)));
assert(!mesh::handleUsbLoggingWatchdogCommand("set usb.debug on", reply, sizeof(reply)));
assert(!mesh::handleUsbLoggingWatchdogCommand(nullptr, reply, sizeof(reply)));
assert(!mesh::handleUsbLoggingWatchdogCommand("get usb.watchdog", reply, 0));
}
''', encoding='ascii')
binary = Path(directory) / 'unsupported.exe'
built = subprocess.run([
compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror',
'-DARDUINO', '-DESP32', '-DESP32_PLATFORM',
'-I' + str(FIXTURE / 'mocks'),
'-I' + str(ROOT / 'src' / 'helpers'), '-I' + str(ROOT / 'src'),
str(ROOT / 'src' / 'helpers' / 'UsbLoggingWatchdog.cpp'),
str(ROOT / 'src' / 'helpers' / 'UsbLoggingClientActivity.cpp'),
str(harness), '-o', str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stderr)
tested = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(tested.returncode, 0, tested.stderr)
if __name__ == '__main__':
unittest.main()
+233
View File
@@ -0,0 +1,233 @@
"""Compile the production USB watchdog event codec without Arduino or PIO."""
from pathlib import Path
import os
import shutil
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <helpers/UsbLoggingWatchdogEvent.h>
#include <array>
#include <cassert>
#include <cstdio>
#include <cstring>
using Event = mesh::UsbLoggingWatchdogEvent;
constexpr size_t Size = mesh::USB_WATCHDOG_EVENT_SIZE;
static_assert(Size == 13, "The management extension is exactly thirteen bytes");
static bool same(const Event& lhs, const Event& rhs) {
return lhs.reasons == rhs.reasons && lhs.action == rhs.action
&& lhs.epoch == rhs.epoch && lhs.uptime_seconds == rhs.uptime_seconds
&& lhs.sequence == rhs.sequence && lhs.persisted == rhs.persisted;
}
static Event populated() {
Event event;
event.reasons = Event::HOST_ABSENT | Event::TX_STALLED | Event::CLIENT_INACTIVE;
event.action = Event::REBOOT_REQUESTED;
event.epoch = 0x12345678;
event.uptime_seconds = 0x90abcdef;
event.sequence = 0x10203040;
event.persisted = true;
return event;
}
static void exact_little_endian_and_bounds() {
const auto event = populated();
std::array<uint8_t, Size + 2> output;
output.fill(0x5a);
mesh::encodeUsbWatchdogEvent(output.data() + 1, event);
const uint8_t expected[Size] = {
0xbd, 0x78, 0x56, 0x34, 0x12, 0xef, 0xcd, 0xab, 0x90,
0x40, 0x30, 0x20, 0x10};
assert(output.front() == 0x5a && output.back() == 0x5a);
assert(!std::memcmp(output.data() + 1, expected, Size));
Event decoded;
assert(mesh::validUsbWatchdogEvent(expected));
assert(mesh::decodeUsbWatchdogEvent(expected, decoded));
assert(same(event, decoded));
}
static void zero_sequence_has_one_canonical_encoding() {
auto event = populated();
event.sequence = 0;
uint8_t output[Size];
std::memset(output, 0x5a, Size);
mesh::encodeUsbWatchdogEvent(output, event);
for (const auto byte : output) assert(byte == 0);
auto decoded = populated();
assert(mesh::validUsbWatchdogEvent(output));
assert(mesh::decodeUsbWatchdogEvent(output, decoded));
assert(same(decoded, Event()));
mesh::encodeUsbWatchdogEvent(output, Event());
for (const auto byte : output) assert(byte == 0);
}
static void roundtrip_every_reason_action_and_persistence_bit() {
unsigned count = 0;
for (uint8_t reasons = 1; reasons <= 15; ++reasons) {
for (uint8_t action = Event::SOFT_RECOVERY; action <= Event::REBOOT_CANCELLED; ++action) {
for (unsigned persisted = 0; persisted != 2; ++persisted) {
auto event = populated();
event.reasons = reasons;
event.action = action;
event.persisted = persisted;
uint8_t output[Size];
mesh::encodeUsbWatchdogEvent(output, event);
assert(output[0] == (reasons | (action << 4) | (persisted << 7)));
auto decoded = Event();
assert(mesh::validUsbWatchdogEvent(output));
assert(mesh::decodeUsbWatchdogEvent(output, decoded));
assert(same(event, decoded));
++count;
}
}
}
assert(count == 120);
}
static void assert_invalid_preserves_destination(const uint8_t* bytes) {
auto destination = populated();
const auto before = destination;
assert(!mesh::validUsbWatchdogEvent(bytes));
assert(!mesh::decodeUsbWatchdogEvent(bytes, destination));
assert(same(before, destination));
}
static void invalid_zero_sequence_mixed_data_and_invalid_actions() {
// With sequence zero, every other wire bit must be zero too. This covers
// reasons, action, persistence, timestamp and uptime independently.
for (size_t index = 0; index != 9; ++index) {
for (unsigned bit = 0; bit != 8; ++bit) {
uint8_t bytes[Size] = {};
bytes[index] = uint8_t(1U << bit);
assert_invalid_preserves_destination(bytes);
}
}
uint8_t bytes[Size];
mesh::encodeUsbWatchdogEvent(bytes, populated());
bytes[0] = Event::HOST_ABSENT; // A nonempty record cannot use NONE.
assert_invalid_preserves_destination(bytes);
bytes[0] = Event::SOFT_RECOVERY << 4; // Nor can it have no reason.
assert_invalid_preserves_destination(bytes);
for (unsigned action = 5; action <= 7; ++action) {
for (unsigned persisted = 0; persisted != 2; ++persisted) {
bytes[0] = uint8_t(Event::HOST_ABSENT | (action << 4) | (persisted << 7));
assert_invalid_preserves_destination(bytes);
}
}
}
static void epoch_zero_and_uint32_boundaries_are_valid() {
for (unsigned maximum = 0; maximum != 2; ++maximum) {
auto event = populated();
event.epoch = maximum ? UINT32_MAX : 0;
event.uptime_seconds = maximum ? UINT32_MAX : 0;
event.sequence = maximum ? UINT32_MAX : 1;
event.persisted = maximum;
uint8_t bytes[Size];
mesh::encodeUsbWatchdogEvent(bytes, event);
Event decoded;
assert(mesh::validUsbWatchdogEvent(bytes));
assert(mesh::decodeUsbWatchdogEvent(bytes, decoded));
assert(same(event, decoded));
for (size_t index = 1; index != 9; ++index) {
assert(bytes[index] == (maximum ? 0xff : 0));
}
}
}
static void wire_validation_matches_decode_for_every_flag_and_sequence_byte() {
for (unsigned code = 0; code <= 255; ++code) {
for (size_t sequence_byte = 9; sequence_byte < Size; ++sequence_byte) {
uint8_t bytes[Size] = {};
bytes[0] = uint8_t(code);
bytes[1] = 0x5a; // Arbitrary advisory clock and uptime are allowed.
bytes[8] = 0xa5;
bytes[sequence_byte] = 0x80;
const unsigned reasons = code & 15;
const unsigned action = (code >> 4) & 7;
const bool expected = reasons != 0 && action >= 1 && action <= 4;
assert(mesh::validUsbWatchdogEvent(bytes) == expected);
Event decoded;
assert(mesh::decodeUsbWatchdogEvent(bytes, decoded) == expected);
}
}
}
static void action_names_are_stable_and_unknown_is_none() {
const char* expected[] = {"none", "soft-recovery", "reenumerate",
"reboot-requested", "reboot-cancelled"};
for (unsigned action = 0; action <= 255; ++action) {
assert(!std::strcmp(mesh::usbWatchdogActionName(uint8_t(action)),
action < 5 ? expected[action] : "none"));
}
}
int main(int argc, char** argv) {
assert(argc == 2);
if (!std::strcmp(argv[1], "bytes")) exact_little_endian_and_bounds();
else if (!std::strcmp(argv[1], "empty")) zero_sequence_has_one_canonical_encoding();
else if (!std::strcmp(argv[1], "roundtrip")) roundtrip_every_reason_action_and_persistence_bit();
else if (!std::strcmp(argv[1], "invalid")) invalid_zero_sequence_mixed_data_and_invalid_actions();
else if (!std::strcmp(argv[1], "limits")) epoch_zero_and_uint32_boundaries_are_valid();
else if (!std::strcmp(argv[1], "validation")) wire_validation_matches_decode_for_every_flag_and_sequence_byte();
else if (!std::strcmp(argv[1], "names")) action_names_are_stable_and_unknown_is_none();
else return 2;
std::puts("USB watchdog event codec passed");
}
'''
class UsbWatchdogEventTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
compiler = os.environ.get('CXX') or shutil.which('g++') or shutil.which('clang++')
if not compiler:
raise unittest.SkipTest('a host C++ compiler is required')
cls.directory = tempfile.TemporaryDirectory(prefix='mesh-usb-watchdog-event-')
cls.addClassCleanup(cls.directory.cleanup)
source = Path(cls.directory.name) / 'event.cpp'
source.write_text(HARNESS, encoding='ascii')
cls.executable = Path(cls.directory.name) / 'event'
command = [compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', '-pedantic',
'-O2', '-I', str(ROOT / 'src'), str(source), '-o', str(cls.executable)]
result = subprocess.run(command, capture_output=True, text=True, timeout=60)
if result.returncode:
raise AssertionError(result.stdout + result.stderr)
def run_case(self, name):
result = subprocess.run([str(self.executable), name], capture_output=True,
text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('USB watchdog event codec passed', result.stdout)
def test_exact_thirteen_byte_little_endian_layout_and_output_bounds(self):
self.run_case('bytes')
def test_empty_event_canonical_encoding_resets_decoded_destination(self):
self.run_case('empty')
def test_all_reason_masks_actions_and_persistence_bits_roundtrip(self):
self.run_case('roundtrip')
def test_malformed_records_fail_without_mutating_destination(self):
self.run_case('invalid')
def test_zero_epoch_and_maximum_uint32_fields_are_valid(self):
self.run_case('limits')
def test_action_names(self):
self.run_case('names')
def test_wire_only_validation_matches_decode_for_all_flags_and_sequence_bytes(self):
self.run_case('validation')
if __name__ == '__main__':
unittest.main()
+80 -18
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Decode and authenticate complete MGR1 management reports.
"""Decode and authenticate complete MGR1/MGR2/MGR3 management reports.
Input is a JSON list of raw payload hex strings, one entry for each page (not
MeshCore packet headers/paths). A relay/collector can obtain these from RX logs
@@ -14,6 +14,66 @@ import struct
from pathlib import Path
HEADER, ENTRY, PER_PAGE, TAG, MAX_KEYS = 83, 13, 6, 16, 36
MGR2_HEADER, MGR2_PER_PAGE = 98, 5
CURRENT_HEADER, CURRENT_PER_PAGE = 111, 4
def _layout(payload):
formats = {b"MGR1": (HEADER, PER_PAGE), b"MGR2": (MGR2_HEADER, MGR2_PER_PAGE),
b"MGR3": (CURRENT_HEADER, CURRENT_PER_PAGE)}
result = formats.get(payload[:4])
if result is None or len(payload) < result[0] + TAG:
raise ValueError("invalid management payload")
return result
def _page_bounds(payload):
header, per_page = _layout(payload)
if header >= MGR2_HEADER and (int.from_bytes(payload[83:85], "little") & 0xf800 or payload[85] & 0xc0):
raise ValueError("reserved USB status bits must be zero")
if header == CURRENT_HEADER:
_watchdog_event(payload)
page, pages, total, first, count = payload[78:83]
expected = max(1, (total + per_page - 1) // per_page)
if (total > MAX_KEYS or pages != expected or page >= pages or
first != page * per_page or first > total or
count != min(per_page, total - first)):
raise ValueError("invalid management page bounds")
return header, header + count * ENTRY + TAG
def _usb_status(payload):
if payload[:4] not in (b"MGR2", b"MGR3"):
return None
flags = int.from_bytes(payload[83:85], "little")
names = ("supported", "logging_enabled", "watchdog_enabled", "host_connected",
"reader_connected", "stalled", "recovering", "recovery_deferred",
"persistence_ready", "watchdog_auto", "logger_active")
result = {name: bool(flags & (1 << bit)) for bit, name in enumerate(names)}
result.update(stage=payload[85] & 3, backoff_step=(payload[85] >> 2) & 15,
retry_seconds=int.from_bytes(payload[86:90], "little"),
inactive_seconds=int.from_bytes(payload[90:94], "little"),
auto_connected_seconds=int.from_bytes(payload[94:98], "little"))
return result
def _watchdog_event(payload):
if payload[:4] != b"MGR3":
return None
code = payload[98]
epoch, uptime, sequence = struct.unpack_from("<III", payload, 99)
if not sequence:
if any(payload[98:111]):
raise ValueError("invalid empty USB watchdog event")
return None
reasons, action = code & 15, (code >> 4) & 7
if not reasons or not 1 <= action <= 4:
raise ValueError("invalid USB watchdog event reason/action")
names = ("host-absent", "reader-absent", "tx-stalled", "client-inactive")
actions = (None, "soft-recovery", "reenumerate", "reboot-requested", "reboot-cancelled")
return dict(reasons=reasons, reason_names=[name for bit, name in enumerate(names) if reasons & (1 << bit)],
action_code=action, action=actions[action], persisted=bool(code & 128),
epoch=epoch, uptime_seconds=uptime, sequence=sequence)
def password_key(password):
@@ -54,18 +114,14 @@ def _extrema(raw):
def decode_page(payload, password):
from Crypto.Cipher import AES # PyCryptodome; independent AES-SIV implementation
if not HEADER + TAG <= len(payload) <= 177 or payload[:4] != b"MGR1":
raise ValueError("invalid management payload")
page, pages, total, first, count = payload[78:83]
expected = max(1, (total + PER_PAGE - 1) // PER_PAGE)
if (total > MAX_KEYS or pages != expected or page >= pages or
first != page * PER_PAGE or first > total or
count != min(PER_PAGE, total - first) or len(payload) != HEADER + count * ENTRY + TAG):
header, canonical = _page_bounds(payload)
count = payload[82]
if len(payload) != canonical or len(payload) > 179:
raise ValueError("invalid management page bounds")
key = derive(password_key(password), "MeshCore-MGR1-SIV", payload[4:20])
cipher = AES.new(key, AES.MODE_SIV)
cipher.update(payload[:HEADER])
private = cipher.decrypt_and_verify(payload[HEADER:-TAG], payload[-TAG:])
cipher.update(payload[:header])
private = cipher.decrypt_and_verify(payload[header:-TAG], payload[-TAG:])
entries = []
for i in range(count):
token = private[i * ENTRY:i * ENTRY + 12]
@@ -77,7 +133,7 @@ def decode_page(payload, password):
def mqtt_payload(message):
"""Extract an MGR1 payload from the observer's MQTT PACKET/raw JSON.
"""Extract an MGR1/MGR2/MGR3 payload from the observer's MQTT PACKET/raw JSON.
Path length is encoded, not simply a byte count. Region transport codes
precede it. Never trust the redundant MQTT payload_len/type fields.
@@ -103,14 +159,15 @@ def mqtt_payload(message):
start = position + 1 + width * count
payload = packet[start:]
if (packet[0] >> 2) & 15 == 6:
if len(payload) < HEADER + TAG or payload[:4] != b"MGR1":
try:
_, canonical = _page_bounds(payload)
except ValueError:
return None
canonical = HEADER + payload[82] * ENTRY + TAG
padded = 3 + ((canonical - 3 + 15) // 16) * 16
if len(payload) != padded or any(payload[canonical:]):
return None
payload = payload[:canonical]
return payload if payload[:4] == b"MGR1" else None
return payload if payload[:4] in (b"MGR1", b"MGR2", b"MGR3") else None
def mqtt_reports(messages, password):
@@ -140,8 +197,8 @@ def mqtt_reports(messages, password):
def decode_report(payloads, password):
if not payloads or len(payloads) > 6:
raise ValueError("one through six pages required")
if not payloads or len(payloads) > 9:
raise ValueError("one through nine pages required")
# Check every page before indexing metadata, then reject omissions,
# duplicates, mixed snapshots and unauthenticated public fields.
decoded = [(p, decode_page(p, password)) for p in payloads]
@@ -149,7 +206,9 @@ def decode_report(payloads, password):
first = decoded[0][0]
if len(decoded) != first[79] or [p[78] for p, _ in decoded] != list(range(first[79])):
raise ValueError("incomplete or duplicate report pages")
if any(p[:78] != first[:78] or p[79:81] != first[79:81] for p, _ in decoded):
header, _ = _layout(first)
if any(p[:78] != first[:78] or p[79:81] != first[79:81] or
p[HEADER:header] != first[HEADER:header] for p, _ in decoded):
raise ValueError("mixed report snapshots")
fields = struct.unpack_from("<IIIII", first, 20)
sequence, timestamp, firmware, bootloader, target = fields
@@ -157,7 +216,8 @@ def decode_report(payloads, password):
radio = first[4:20]
def version(value):
return ".".join(str((value >> n) & 255) for n in (24, 16, 8, 0))
result = dict(radio_id=radio.hex(), sequence=sequence, timestamp=timestamp,
result = dict(protocol=first[:4].decode("ascii"),
radio_id=radio.hex(), sequence=sequence, timestamp=timestamp,
firmware_version=version(firmware) if valid & 1 else None,
bootloader_version=version(bootloader) if valid & 2 else None,
target_id=f"{target:08x}" if valid & 4 else None,
@@ -171,6 +231,8 @@ def decode_report(payloads, password):
capability_bits=first[73], active_bits=first[74], known_bits=first[75],
partial_week=bool(valid & 16), partial_period=bool(valid & 32),
temperature_source="MCU" if valid & 64 else "unknown",
usb_logging=_usb_status(first),
usb_watchdog_last=_watchdog_event(first),
acl=[entry for _, entries in decoded for entry in entries])
return result