From 3add4695cc2892cd839896f366b06bfd268f9bba Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 3 Oct 2026 15:29:19 -0700 Subject: [PATCH] Harden USB logging and persist watchdog events in management reports --- .github/workflows/run-unit-tests.yml | 13 +- docs/_javascript/management_decoder.js | 141 +- docs/cli_build_matrix.md | 14 +- docs/cli_command_availability.md | 15 + docs/cli_commands.md | 120 +- docs/full_companion_usb_switcher.md | 30 + docs/management_decoder.md | 40 +- docs/management_reports.md | 118 +- docs/role_feature_switches.md | 32 +- docs/terminal_chat_cli.md | 31 +- .../full_usb_logging_validation.md | 11 +- docs/usb_logging_watchdog.md | 198 +++ examples/companion_radio/DataStore.cpp | 15 + examples/companion_radio/MyMesh.cpp | 215 +++- examples/companion_radio/MyMesh.h | 16 +- examples/companion_radio/NodePrefs.h | 30 + examples/companion_radio/main.cpp | 36 + examples/simple_repeater/MyMesh.cpp | 12 +- examples/simple_repeater/MyMesh.h | 13 +- examples/simple_repeater/main.cpp | 38 +- examples/simple_room_server/MyMesh.cpp | 8 +- examples/simple_room_server/MyMesh.h | 13 +- examples/simple_room_server/main.cpp | 35 +- examples/simple_sensor/SensorMesh.cpp | 2 +- examples/simple_sensor/SensorMesh.h | 5 + examples/simple_sensor/main.cpp | 32 +- src/Mesh.cpp | 2 +- src/MeshCore.h | 12 +- src/helpers/AlertReporter.cpp | 2 +- src/helpers/CommonCLI.cpp | 51 +- src/helpers/CommonCLI.h | 4 + src/helpers/ManagementReport.cpp | 2 +- src/helpers/ManagementReport.h | 71 +- src/helpers/ManagementReporter.cpp | 20 +- src/helpers/NetworkLink.cpp | 3 +- src/helpers/NonBlockingWriteStream.h | 7 +- src/helpers/UsbLogging.cpp | 326 ++++- src/helpers/UsbLogging.h | 26 +- src/helpers/UsbLoggingClientActivity.cpp | 53 + src/helpers/UsbLoggingClientActivity.h | 70 + src/helpers/UsbLoggingLineStateOverride.cpp | 11 +- src/helpers/UsbLoggingStatus.h | 55 + src/helpers/UsbLoggingWatchdog.cpp | 588 +++++++++ src/helpers/UsbLoggingWatchdog.h | 24 + src/helpers/UsbLoggingWatchdogEvent.h | 82 ++ src/helpers/UsbLoggingWatchdogPolicy.h | 192 +++ src/helpers/bridges/MQTTBridge.cpp | 2 +- src/helpers/bridges/MQTTBridge.h | 4 +- src/helpers/esp32/ESPNOWRadio.h | 4 +- src/helpers/esp32/SerialBLEInterface.h | 4 +- src/helpers/esp32/TBeamBoard.cpp | 4 +- src/helpers/esp32/WebConfigServer.cpp | 54 +- .../ethernet/SerialEthernetInterface.h | 6 +- src/helpers/nrf52/SerialBLEInterface.h | 4 +- src/helpers/ota/OtaDebug.h | 4 +- src/helpers/sensors/AirohaSleep.h | 2 +- .../sensors/MicroNMEALocationProvider.h | 4 +- src/helpers/ui/CHSC6XTouch.h | 23 +- src/helpers/wifi/SerialWifiInterface.h | 4 +- .../esp32_tinyusb_nonblocking/mocks/Arduino.h | 3 + .../mocks/esp32-hal-tinyusb.h | 3 + .../test_esp32_tinyusb_nonblocking.cpp | 92 ++ test/fixtures/management/protocol_test.cpp | 97 +- test/fixtures/management/runtime_test.cpp | 45 +- .../mocks/Adafruit_TinyUSB.h | 17 + .../nrf52_usb_console/mocks/Arduino.h | 22 + .../nrf52_usb_console/mocks/class/cdc/cdc.h | 3 + .../nrf52_usb_console/test_console.cpp | 131 ++ .../usb_logging_watchdog/mocks/Arduino.h | 8 + test/fixtures/usb_logging_watchdog/mocks/FS.h | 98 ++ .../usb_logging_watchdog/mocks/Identity.h | 2 + .../mocks/ota/OtaContext.h | 23 + .../usb_logging_watchdog/mocks/sys/stat.h | 19 + .../test_usb_logging_watchdog_runtime.cpp | 1135 +++++++++++++++++ test/test_common_radio_persistence.py | 8 +- test/test_common_usb_debug.py | 358 ++++++ test/test_companion_bluetooth_persistence.py | 16 +- test/test_companion_identity_cli.py | 203 +++ .../test_companion_preferences_transaction.py | 2 +- test/test_companion_prefs_transactions.py | 61 +- test/test_companion_usb_logging.py | 452 +++++++ test/test_esp32_tinyusb_cooperative_output.py | 4 +- test/test_esp32_tinyusb_nonblocking.py | 1 + test/test_esp32_tinyusb_role_hygiene.py | 9 +- test/test_esp32_usb_serial_hygiene.py | 10 +- test/test_management_decoder.js | 78 ++ test/test_management_report.py | 169 ++- .../test_nrf52_debug_output.cpp | 16 + test/test_nrf52_usb_client.py | 18 +- test/test_nrf52_usb_console.py | 42 + test/test_nrf52_usb_logging_contract.py | 2 +- test/test_trace_retry/test_trace_retry.cpp | 66 +- test/test_usb_logging_client_activity.py | 195 +++ test/test_usb_logging_watchdog.py | 501 ++++++++ test/test_usb_logging_watchdog_roles.py | 287 +++++ test/test_usb_logging_watchdog_runtime.py | 99 ++ test/test_usb_watchdog_event.py | 233 ++++ tools/management/report.py | 98 +- 98 files changed, 7207 insertions(+), 372 deletions(-) create mode 100644 docs/usb_logging_watchdog.md create mode 100644 src/helpers/UsbLoggingClientActivity.cpp create mode 100644 src/helpers/UsbLoggingClientActivity.h create mode 100644 src/helpers/UsbLoggingStatus.h create mode 100644 src/helpers/UsbLoggingWatchdog.cpp create mode 100644 src/helpers/UsbLoggingWatchdog.h create mode 100644 src/helpers/UsbLoggingWatchdogEvent.h create mode 100644 src/helpers/UsbLoggingWatchdogPolicy.h create mode 100644 test/fixtures/nrf52_usb_console/mocks/Adafruit_TinyUSB.h create mode 100644 test/fixtures/nrf52_usb_console/mocks/Arduino.h create mode 100644 test/fixtures/nrf52_usb_console/mocks/class/cdc/cdc.h create mode 100644 test/fixtures/nrf52_usb_console/test_console.cpp create mode 100644 test/fixtures/usb_logging_watchdog/mocks/Arduino.h create mode 100644 test/fixtures/usb_logging_watchdog/mocks/FS.h create mode 100644 test/fixtures/usb_logging_watchdog/mocks/Identity.h create mode 100644 test/fixtures/usb_logging_watchdog/mocks/ota/OtaContext.h create mode 100644 test/fixtures/usb_logging_watchdog/mocks/sys/stat.h create mode 100644 test/fixtures/usb_logging_watchdog/test_usb_logging_watchdog_runtime.cpp create mode 100644 test/test_common_usb_debug.py create mode 100644 test/test_companion_identity_cli.py create mode 100644 test/test_companion_usb_logging.py create mode 100644 test/test_nrf52_usb_console.py create mode 100644 test/test_usb_logging_client_activity.py create mode 100644 test/test_usb_logging_watchdog.py create mode 100644 test/test_usb_logging_watchdog_roles.py create mode 100644 test/test_usb_logging_watchdog_runtime.py create mode 100644 test/test_usb_watchdog_event.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 40972e2bc..000ba4fbb 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -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 diff --git a/docs/_javascript/management_decoder.js b/docs/_javascript/management_decoder.js index 9b6bdb4e1..0afd745c1 100644 --- a/docs/_javascript/management_decoder.js +++ b/docs/_javascript/management_decoder.js @@ -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(); diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index dd8841e9b..ea7668aff 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -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 diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 7f78f545a..800694fac 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -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 ` | 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 `](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 `](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 | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 58a32ac75..62a6f0673 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -462,7 +462,7 @@ command `0x42` where the role supports the command; unavailable over LoRa. ### Remove a neighbor **Usage:** - `neighbor.remove ` -- `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:` 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` and +`T` 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 diff --git a/docs/full_companion_usb_switcher.md b/docs/full_companion_usb_switcher.md index f76123a76..0a9b4e5bd 100644 --- a/docs/full_companion_usb_switcher.md +++ b/docs/full_companion_usb_switcher.md @@ -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. diff --git a/docs/management_decoder.md b/docs/management_decoder.md index ec33accdf..50268a3f2 100644 --- a/docs/management_decoder.md +++ b/docs/management_decoder.md @@ -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
- +

@@ -31,7 +31,7 @@ by someone who does not know that password.

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

@@ -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). diff --git a/docs/management_reports.md b/docs/management_reports.md index f9604c214..1cbd945b4 100644 --- a/docs/management_reports.md +++ b/docs/management_reports.md @@ -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 diff --git a/docs/role_feature_switches.md b/docs/role_feature_switches.md index 558a86663..2af39931e 100644 --- a/docs/role_feature_switches.md +++ b/docs/role_feature_switches.md @@ -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 diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 2e45e01da..1f4cc99fd 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -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 diff --git a/docs/test-results/full_usb_logging_validation.md b/docs/test-results/full_usb_logging_validation.md index fa69d176c..c0840e7ed 100644 --- a/docs/test-results/full_usb_logging_validation.md +++ b/docs/test-results/full_usb_logging_validation.md @@ -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 diff --git a/docs/usb_logging_watchdog.md b/docs/usb_logging_watchdog.md new file mode 100644 index 000000000..b13a431f4 --- /dev/null +++ b/docs/usb_logging_watchdog.md @@ -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`. diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index e87c2bfe7..bf8e047e1 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -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); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index c2fadae07..197fa2ca9 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -25,6 +25,10 @@ #include #include #include +#include +#if MESH_PACKET_LOGGING +#include +#endif #if defined(NRF52_POWER_MANAGEMENT) #include #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 "); + } 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 on|off (!notify, max 15s, no GPIO)\r\n"); terminalOutput().print(" notify.test / notify.stop / notify.delete \r\n"); #endif - terminalOutput().print(" get display.inbox\r\n"); - terminalOutput().print(" set display.inbox \r\n"); - terminalOutput().print(" set display.rotation <0|90|180|270>\r\n"); - terminalOutput().print(" get display.touch\r\n"); - terminalOutput().print(" set display.touch (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 \r\n"); + terminalOutput().print( + " get display.inbox\r\n" + " set display.inbox \r\n" + " set display.rotation <0|90|180|270>\r\n" + " get display.touch\r\n" + " set display.touch (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 \r\n"); #if defined(BLE_PIN_CODE) terminalOutput().print(" get bluetooth (alias: get ble)\r\n"); terminalOutput().print(" set bluetooth [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 [reboot]\r\n"); + terminalOutput().print( + " get usb.logging\r\n" + " set usb.logging [reboot]\r\n" + " get usb.debug\r\n" + " set usb.debug \r\n" +#if MESH_USB_CONSOLE_COOPERATIVE + " get usb.watchdog\r\n" + " get usb.watchdog.last\r\n" + " set usb.watchdog (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 \r\n"); - terminalOutput().print(" get radio.cad\r\n"); - terminalOutput().print(" set radio.cad \r\n"); - terminalOutput().print(" set radio.cad timings \r\n"); - terminalOutput().print(" get radio.rxgain\r\n"); - terminalOutput().print(" set radio.rxgain \r\n"); - terminalOutput().print(" get radio.fem.rxgain\r\n"); - terminalOutput().print(" set radio.fem.rxgain \r\n"); - terminalOutput().print(" get radio.fem.txgain\r\n"); - terminalOutput().print(" set radio.fem.txgain \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 \r\n"); - terminalOutput().print(" card\r\n"); - terminalOutput().print(" import \r\n"); - terminalOutput().print(" clock\r\n"); - terminalOutput().print(" time \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 \r\n"); - terminalOutput().print(" login \r\n"); - terminalOutput().print(" cmd \r\n"); - terminalOutput().print(" trace [recipient name or prefix]\r\n"); - terminalOutput().print(" trace path <1|2|4> \r\n"); - terminalOutput().print(" advert\r\n"); - terminalOutput().print(" reset path\r\n"); - terminalOutput().print(" public \r\n"); - terminalOutput().print(" channels\r\n"); - terminalOutput().print(" channel \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 \r\n" + " get radio.cad\r\n" + " set radio.cad \r\n" + " set radio.cad timings \r\n"); + terminalOutput().print( + " get radio.rxgain\r\n" + " set radio.rxgain \r\n" + " get radio.fem.rxgain\r\n" + " set radio.fem.rxgain \r\n" + " get radio.fem.txgain\r\n" + " set radio.fem.txgain \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 \r\n" + " card\r\n" + " import \r\n" + " clock\r\n" + " time \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 \r\n" + " login \r\n" + " cmd \r\n"); + terminalOutput().print( + " trace [recipient name or prefix]\r\n" + " trace path <1|2|4> \r\n" + " advert\r\n" + " reset path\r\n" + " public \r\n" + " channels\r\n" + " channel \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; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index afcfbe4fe..64a4c5729 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -49,6 +49,7 @@ #include #include #include +#include #include #if defined(OTA_SHARED_COMPANION_QUEUE) #include @@ -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 diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index a3350b26c..24f016297 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -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; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 192a129a4..9d8685929 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -1,6 +1,8 @@ #include // needed for PlatformIO #include #include +#include +#include #include #include #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. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 0c6897976..9c2c99c35 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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(); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 13875a4ed..9b3932120 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -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 { diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 640fd71fc..bcab64586 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #if MESH_PACKET_LOGGING #include #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(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 \ diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index ca8398ce9..7af80aa33 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -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(); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 9d540ecd9..242dc1658 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -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 { diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index 66d0c4b3f..e55878364 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #if MESH_PACKET_LOGGING #include #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(sizeof(command) - 1) + && strlen(command) == static_cast(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 \ diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index a5859948e..f206575ae 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -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); diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index db8a38fe2..a17c8f167 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -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); diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 1e8db473a..c1bda814a 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -7,6 +7,8 @@ #include #endif #include +#include +#include #if defined(ESP32_PLATFORM) #include @@ -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(sizeof(command) - 1) + && strlen(command) == static_cast(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) diff --git a/src/Mesh.cpp b/src/Mesh.cpp index a9048e041..2ae164ad2 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -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 && diff --git a/src/MeshCore.h b/src/MeshCore.h index e83328f6a..77d3d9ed4 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -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(...) {} diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index 0cf24ef47..4615f8713 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -24,7 +24,7 @@ #ifdef MQTT_DEBUG #include -#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 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index dcafe3e2a..ecf51088c 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,6 +1,7 @@ #include #include "CommonCLI.h" #include "PrefsSaveReplyGuard.h" +#include "UsbLoggingWatchdog.h" #include #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) { diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 65342906c..2293bcccc 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -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: diff --git a/src/helpers/ManagementReport.cpp b/src/helpers/ManagementReport.cpp index 430f6aa1c..2b071cca2 100644 --- a/src/helpers/ManagementReport.cpp +++ b/src/helpers/ManagementReport.cpp @@ -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); } diff --git a/src/helpers/ManagementReport.h b/src/helpers/ManagementReport.h index 3e6dd3f34..2f1cc9ee3 100644 --- a/src/helpers/ManagementReport.h +++ b/src/helpers/ManagementReport.h @@ -4,12 +4,20 @@ #include #include #include +#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; + } }; } } diff --git a/src/helpers/ManagementReporter.cpp b/src/helpers/ManagementReporter.cpp index e3b74c192..5bd5fb7cd 100644 --- a/src/helpers/ManagementReporter.cpp +++ b/src/helpers/ManagementReporter.cpp @@ -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; } diff --git a/src/helpers/NetworkLink.cpp b/src/helpers/NetworkLink.cpp index ba87b35a5..625be1f73 100644 --- a/src/helpers/NetworkLink.cpp +++ b/src/helpers/NetworkLink.cpp @@ -4,6 +4,7 @@ #include "MQTTConnectionPolicy.h" #include "WifiPowerSavePolicy.h" +#include "UsbLogging.h" #include #include @@ -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 diff --git a/src/helpers/NonBlockingWriteStream.h b/src/helpers/NonBlockingWriteStream.h index 832e47642..1d1b370a1 100644 --- a/src/helpers/NonBlockingWriteStream.h +++ b/src/helpers/NonBlockingWriteStream.h @@ -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; diff --git a/src/helpers/UsbLogging.cpp b/src/helpers/UsbLogging.cpp index 1d511adb8..1aaa3feca 100644 --- a/src/helpers/UsbLogging.cpp +++ b/src/helpers/UsbLogging.cpp @@ -1,4 +1,5 @@ #include "UsbLogging.h" +#include "UsbLoggingClientActivity.h" #if defined(ARDUINO) #include @@ -17,12 +18,15 @@ #if MESH_ESP32_TINYUSB_NONBLOCKING #include "esp32-hal-tinyusb.h" #endif +#if defined(NRF52_PLATFORM) && defined(USE_TINYUSB) + #include +#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 #if !defined(CFG_TUD_CDC) || CFG_TUD_CDC < 1 @@ -59,6 +63,23 @@ static std::atomic usb_logging_enabled{false}; static std::atomic usb_logging_enabled{true}; #endif static std::atomic usb_logging_preference_known{false}; +static std::atomic usb_debug_enabled{false}; +static std::atomic usb_logging_tx_progress{0}; +static std::atomic 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(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 esp32_hwcdc_tx_kick_pending{false}; +static std::atomic 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 primary_usb_reset_generation{0}; static std::atomic primary_usb_allowed_generation{0}; +static std::atomic primary_usb_terminal_discard_pending{false}; static std::atomic primary_usb_line_state_dtr{false}; static std::atomic 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(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(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(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 dedicated_usb_logging_port_connected{false}; +static std::atomic 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(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(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 diff --git a/src/helpers/UsbLogging.h b/src/helpers/UsbLogging.h index fa4a41b5a..e1cccb81b 100644 --- a/src/helpers/UsbLogging.h +++ b/src/helpers/UsbLogging.h @@ -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 diff --git a/src/helpers/UsbLoggingClientActivity.cpp b/src/helpers/UsbLoggingClientActivity.cpp new file mode 100644 index 000000000..12187f3f2 --- /dev/null +++ b/src/helpers/UsbLoggingClientActivity.cpp @@ -0,0 +1,53 @@ +#include "UsbLoggingClientActivity.h" + +#if defined(ARDUINO) +#include +#include "UsbLogging.h" +#if MESH_USB_CONSOLE_COOPERATIVE +#include + +namespace mesh { +namespace { +std::atomic session_generation{1}; +std::atomic stats_generation{0}; +std::atomic 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 diff --git a/src/helpers/UsbLoggingClientActivity.h b/src/helpers/UsbLoggingClientActivity.h new file mode 100644 index 000000000..bc03f838c --- /dev/null +++ b/src/helpers/UsbLoggingClientActivity.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include + +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(c) < 32 && c != '\t') + || static_cast(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 diff --git a/src/helpers/UsbLoggingLineStateOverride.cpp b/src/helpers/UsbLoggingLineStateOverride.cpp index 20f696bfa..6f5de5207 100644 --- a/src/helpers/UsbLoggingLineStateOverride.cpp +++ b/src/helpers/UsbLoggingLineStateOverride.cpp @@ -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 +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 extern "C" void meshTinyUsbCdcLineStateChanged(uint8_t instance, bool dtr, diff --git a/src/helpers/UsbLoggingStatus.h b/src/helpers/UsbLoggingStatus.h new file mode 100644 index 000000000..9aa92b5b2 --- /dev/null +++ b/src/helpers/UsbLoggingStatus.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#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 diff --git a/src/helpers/UsbLoggingWatchdog.cpp b/src/helpers/UsbLoggingWatchdog.cpp new file mode 100644 index 000000000..9827b1625 --- /dev/null +++ b/src/helpers/UsbLoggingWatchdog.cpp @@ -0,0 +1,588 @@ +#if defined(ARDUINO) +#include "UsbLoggingWatchdog.h" +#include "UsbLoggingWatchdogPolicy.h" +#include "UsbLogging.h" +#include "UsbLoggingClientActivity.h" +#include +#include + +#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 +#include + +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 status_flags{0}, status_stage_step{0}; +std::atomic status_retry{3600}, status_inactive{0}; +std::atomic status_recoveries{0}, status_reboots{0}; +std::atomic status_auto_seconds{0}; +std::atomic status_event_guard{0}, status_event_code{0}; +std::atomic 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(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); +#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 diff --git a/src/helpers/UsbLoggingWatchdog.h b/src/helpers/UsbLoggingWatchdog.h new file mode 100644 index 000000000..2e801e68f --- /dev/null +++ b/src/helpers/UsbLoggingWatchdog.h @@ -0,0 +1,24 @@ +#pragma once + +#include "IdentityStore.h" +#include "UsbLoggingStatus.h" +#include + +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 diff --git a/src/helpers/UsbLoggingWatchdogEvent.h b/src/helpers/UsbLoggingWatchdogEvent.h new file mode 100644 index 000000000..47f24f0a2 --- /dev/null +++ b/src/helpers/UsbLoggingWatchdogEvent.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include + +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 diff --git a/src/helpers/UsbLoggingWatchdogPolicy.h b/src/helpers/UsbLoggingWatchdogPolicy.h new file mode 100644 index 000000000..a0c83eeca --- /dev/null +++ b/src/helpers/UsbLoggingWatchdogPolicy.h @@ -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 diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 290da840c..4cd401f1b 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -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\"," diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 9592628bb..128d963c1 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -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(...) {} diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index c977598d2..128e420af 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -71,8 +71,8 @@ public: #if ESPNOW_DEBUG_LOGGING && ARDUINO #include - #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(...) {} diff --git a/src/helpers/esp32/SerialBLEInterface.h b/src/helpers/esp32/SerialBLEInterface.h index 7b1a06bfb..6385b5501 100644 --- a/src/helpers/esp32/SerialBLEInterface.h +++ b/src/helpers/esp32/SerialBLEInterface.h @@ -196,8 +196,8 @@ public: #if BLE_DEBUG_LOGGING && ARDUINO #include - #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(...) {} diff --git a/src/helpers/esp32/TBeamBoard.cpp b/src/helpers/esp32/TBeamBoard.cpp index 55770a6d5..3b26b5ebf 100644 --- a/src/helpers/esp32/TBeamBoard.cpp +++ b/src/helpers/esp32/TBeamBoard.cpp @@ -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"); diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 2f31facc2..65e2cce89 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -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); diff --git a/src/helpers/ethernet/SerialEthernetInterface.h b/src/helpers/ethernet/SerialEthernetInterface.h index 1283c7a03..2204a696d 100644 --- a/src/helpers/ethernet/SerialEthernetInterface.h +++ b/src/helpers/ethernet/SerialEthernetInterface.h @@ -92,9 +92,9 @@ class SerialEthernetInterface : public BaseSerialInterface { #if ETHERNET_DEBUG_LOGGING && ARDUINO #include - #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(...) {} diff --git a/src/helpers/nrf52/SerialBLEInterface.h b/src/helpers/nrf52/SerialBLEInterface.h index 1f77ec301..6fd91ec5d 100644 --- a/src/helpers/nrf52/SerialBLEInterface.h +++ b/src/helpers/nrf52/SerialBLEInterface.h @@ -222,8 +222,8 @@ public: #if BLE_DEBUG_LOGGING && ARDUINO #include - #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(...) {} diff --git a/src/helpers/ota/OtaDebug.h b/src/helpers/ota/OtaDebug.h index 75cdaa874..ef9eab336 100644 --- a/src/helpers/ota/OtaDebug.h +++ b/src/helpers/ota/OtaDebug.h @@ -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 #include - #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 diff --git a/src/helpers/sensors/AirohaSleep.h b/src/helpers/sensors/AirohaSleep.h index eade11ce8..c3261203c 100644 --- a/src/helpers/sensors/AirohaSleep.h +++ b/src/helpers/sensors/AirohaSleep.h @@ -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 diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index 6374f5f6e..4cfe41a4a 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -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++; diff --git a/src/helpers/ui/CHSC6XTouch.h b/src/helpers/ui/CHSC6XTouch.h index b54a3651d..4ebaa0f29 100644 --- a/src/helpers/ui/CHSC6XTouch.h +++ b/src/helpers/ui/CHSC6XTouch.h @@ -2,6 +2,7 @@ #include #include +#include #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*) {} diff --git a/src/helpers/wifi/SerialWifiInterface.h b/src/helpers/wifi/SerialWifiInterface.h index a7a86f9ea..2f6d299cb 100644 --- a/src/helpers/wifi/SerialWifiInterface.h +++ b/src/helpers/wifi/SerialWifiInterface.h @@ -89,8 +89,8 @@ public: #if WIFI_DEBUG_LOGGING && ARDUINO #include - #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(...) {} diff --git a/test/fixtures/esp32_tinyusb_nonblocking/mocks/Arduino.h b/test/fixtures/esp32_tinyusb_nonblocking/mocks/Arduino.h index 5a8fb4e4f..3945ce955 100644 --- a/test/fixtures/esp32_tinyusb_nonblocking/mocks/Arduino.h +++ b/test/fixtures/esp32_tinyusb_nonblocking/mocks/Arduino.h @@ -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; }; diff --git a/test/fixtures/esp32_tinyusb_nonblocking/mocks/esp32-hal-tinyusb.h b/test/fixtures/esp32_tinyusb_nonblocking/mocks/esp32-hal-tinyusb.h index 5ef060ee5..561f9ff7e 100644 --- a/test/fixtures/esp32_tinyusb_nonblocking/mocks/esp32-hal-tinyusb.h +++ b/test/fixtures/esp32_tinyusb_nonblocking/mocks/esp32-hal-tinyusb.h @@ -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(); diff --git a/test/fixtures/esp32_tinyusb_nonblocking/test_esp32_tinyusb_nonblocking.cpp b/test/fixtures/esp32_tinyusb_nonblocking/test_esp32_tinyusb_nonblocking.cpp index 16db4d471..803297719 100644 --- a/test/fixtures/esp32_tinyusb_nonblocking/test_esp32_tinyusb_nonblocking.cpp +++ b/test/fixtures/esp32_tinyusb_nonblocking/test_esp32_tinyusb_nonblocking.cpp @@ -3,10 +3,15 @@ #include #include #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"; } diff --git a/test/fixtures/management/protocol_test.cpp b/test/fixtures/management/protocol_test.cpp index a59d65a96..408b26223 100644 --- a/test/fixtures/management/protocol_test.cpp +++ b/test/fixtures/management/protocol_test.cpp @@ -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 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(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 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)); } diff --git a/test/fixtures/management/runtime_test.cpp b/test/fixtures/management/runtime_test.cpp index 5b997f8a1..77fd16060 100644 --- a/test/fixtures/management/runtime_test.cpp +++ b/test/fixtures/management/runtime_test.cpp @@ -6,6 +6,11 @@ #include 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 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()); diff --git a/test/fixtures/nrf52_usb_console/mocks/Adafruit_TinyUSB.h b/test/fixtures/nrf52_usb_console/mocks/Adafruit_TinyUSB.h new file mode 100644 index 000000000..aa5084ce9 --- /dev/null +++ b/test/fixtures/nrf52_usb_console/mocks/Adafruit_TinyUSB.h @@ -0,0 +1,17 @@ +#pragma once +#include +#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(); diff --git a/test/fixtures/nrf52_usb_console/mocks/Arduino.h b/test/fixtures/nrf52_usb_console/mocks/Arduino.h new file mode 100644 index 000000000..de2f8eaa7 --- /dev/null +++ b/test/fixtures/nrf52_usb_console/mocks/Arduino.h @@ -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 + +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; diff --git a/test/fixtures/nrf52_usb_console/mocks/class/cdc/cdc.h b/test/fixtures/nrf52_usb_console/mocks/class/cdc/cdc.h new file mode 100644 index 000000000..cd2f76931 --- /dev/null +++ b/test/fixtures/nrf52_usb_console/mocks/class/cdc/cdc.h @@ -0,0 +1,3 @@ +#pragma once +#include +struct cdc_line_coding_t { uint32_t bit_rate; }; diff --git a/test/fixtures/nrf52_usb_console/test_console.cpp b/test/fixtures/nrf52_usb_console/test_console.cpp new file mode 100644 index 000000000..009350999 --- /dev/null +++ b/test/fixtures/nrf52_usb_console/test_console.cpp @@ -0,0 +1,131 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +MockSerial Serial; +static bool mounted = true, dtr = true, dfu = false; +static uint32_t baud = 115200; +static std::string fifo, host; +static std::function 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(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(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("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(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(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(record.data()), record.size()); + assert(console.availableForWrite() >= 3072 && mesh::canAcceptUsbConsoleCommand()); + const std::string huge(4096, 'F'); + assert(console.write(reinterpret_cast(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"); +} diff --git a/test/fixtures/usb_logging_watchdog/mocks/Arduino.h b/test/fixtures/usb_logging_watchdog/mocks/Arduino.h new file mode 100644 index 000000000..8445ca1c3 --- /dev/null +++ b/test/fixtures/usb_logging_watchdog/mocks/Arduino.h @@ -0,0 +1,8 @@ +#pragma once + +#include +#include + +inline uint32_t mock_watchdog_millis = 0; +inline uint32_t millis() { return mock_watchdog_millis; } +class Stream { public: virtual ~Stream() = default; }; diff --git a/test/fixtures/usb_logging_watchdog/mocks/FS.h b/test/fixtures/usb_logging_watchdog/mocks/FS.h new file mode 100644 index 000000000..b6f592573 --- /dev/null +++ b/test/fixtures/usb_logging_watchdog/mocks/FS.h @@ -0,0 +1,98 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +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> 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 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; +} diff --git a/test/fixtures/usb_logging_watchdog/mocks/Identity.h b/test/fixtures/usb_logging_watchdog/mocks/Identity.h new file mode 100644 index 000000000..6e655f1fe --- /dev/null +++ b/test/fixtures/usb_logging_watchdog/mocks/Identity.h @@ -0,0 +1,2 @@ +#pragma once +namespace mesh { struct LocalIdentity {}; } diff --git a/test/fixtures/usb_logging_watchdog/mocks/ota/OtaContext.h b/test/fixtures/usb_logging_watchdog/mocks/ota/OtaContext.h new file mode 100644 index 000000000..d5c4d3d1f --- /dev/null +++ b/test/fixtures/usb_logging_watchdog/mocks/ota/OtaContext.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +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 diff --git a/test/fixtures/usb_logging_watchdog/mocks/sys/stat.h b/test/fixtures/usb_logging_watchdog/mocks/sys/stat.h new file mode 100644 index 000000000..13b612329 --- /dev/null +++ b/test/fixtures/usb_logging_watchdog/mocks/sys/stat.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +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; +} diff --git a/test/fixtures/usb_logging_watchdog/test_usb_logging_watchdog_runtime.cpp b/test/fixtures/usb_logging_watchdog/test_usb_logging_watchdog_runtime.cpp new file mode 100644 index 000000000..72b06665c --- /dev/null +++ b/test/fixtures/usb_logging_watchdog/test_usb_logging_watchdog_runtime.cpp @@ -0,0 +1,1135 @@ +#include +#include +#include +#include +#include +#include +#include +#if defined(ENABLE_OTA) +#include +#endif +#include +#include +#include +#include +#include +#include +#include +#include + +using Policy = mesh::UsbLoggingWatchdogPolicy; +using AutoArm = mesh::UsbLoggingAutoArmPolicy; +using Observation = mesh::UsbLoggingObservation; +using Recovery = mesh::UsbLoggingRecoveryResult; +using Event = mesh::UsbLoggingWatchdogEvent; + +static Observation observed; +static bool logging_enabled = true; +static Recovery recovery_result = Recovery::Attempted; +static std::vector recovery_calls; +static unsigned probe_calls = 0; +static bool probe_adds_pending = false; +static bool recovery_pending = false; +static bool client_active = true; +static unsigned client_clear_calls = 0; +static uint32_t event_clock = 1772323200; +static uint32_t read_event_clock() { return event_clock; } + +namespace mesh { +bool isUsbLoggingEnabled() { return logging_enabled; } +bool isUsbLoggingTransportRecoveryPending() { return recovery_pending; } +bool isUsbLoggingClientActive() { return client_active; } +void clearUsbLoggingClientActivity() { client_active = false; ++client_clear_calls; } +UsbLoggingObservation observeUsbLoggingTransport() { return observed; } +UsbLoggingRecoveryResult recoverUsbLoggingTransport(uint8_t stage) { + recovery_calls.push_back(stage); + return recovery_result; +} +void probeUsbLoggingTransport() { + ++probe_calls; + if (probe_adds_pending) observed.pending = true; + // Producer acceptance never changes tx_progress. +} +} // namespace mesh + +static bool can_recover(void* context) { return *static_cast(context); } + +static std::vector image(uint8_t mode, uint8_t step = 0, + uint32_t recoveries = 0, uint32_t reboots = 0, + const Event* event = nullptr, bool legacy = false) { + std::vector bytes(legacy ? 20 : 33, 0); + bytes[0] = 'U'; bytes[1] = 'W'; bytes[2] = legacy ? 1 : 2; + bytes[4] = mode; bytes[5] = step; + mesh::storage::writeLE32(bytes.data() + 8, recoveries); + mesh::storage::writeLE32(bytes.data() + 12, reboots); + if (event && !legacy) mesh::encodeUsbWatchdogEvent(bytes.data() + 16, *event); + const size_t crc_offset = bytes.size() - 4; + mesh::storage::writeLE32(bytes.data() + crc_offset, + mesh::storage::updateCRC32(0xffffffffU, bytes.data(), crc_offset) ^ 0xffffffffU); + return bytes; +} + +static std::vector current_image(uint8_t mode, uint8_t step = 0, + uint32_t recoveries = 0, uint32_t reboots = 0) { + auto event = mesh::usbLoggingStatus().last_event; + event.persisted = event.sequence != 0; + return image(mode, step, recoveries, reboots, &event); +} + +static void repair_crc(std::vector& bytes) { + const size_t crc_offset = bytes.size() - 4; + mesh::storage::writeLE32(bytes.data() + crc_offset, + mesh::storage::updateCRC32(0xffffffffU, bytes.data(), crc_offset) ^ 0xffffffffU); +} + +struct Fixture { + fs::FS fs; + bool safe = true; + uint32_t origin = 0; + explicit Fixture(int mode = -1, uint8_t step = 0, + uint32_t recoveries = 0, uint32_t reboots = 0, + uint32_t start = 0) : origin(start) { + observed = {true, false, false, false, 0}; + logging_enabled = true; + recovery_result = Recovery::Attempted; + recovery_calls.clear(); + probe_calls = 0; + probe_adds_pending = false; + recovery_pending = false; + client_active = false; + client_clear_calls = 0; + event_clock = 1772323200; +#if defined(ENABLE_OTA) + mesh::ota::test_ota_context = nullptr; +#endif + mock_watchdog_millis = origin; + fs::stat_filesystem = &fs; + if (mode >= 0) fs.files["/usb_wdg"] = image(uint8_t(mode), step, recoveries, reboots); + mesh::loadUsbLoggingWatchdog(&fs, true, read_event_clock); + // Existing timeline cases model a normally renewing USB stats client. + // Dedicated lease cases below explicitly control client liveness. + client_active = true; + } + void reload(bool durable = true, bool renewed_stats = true) { + fs::stat_filesystem = &fs; + mesh::loadUsbLoggingWatchdog(&fs, durable, read_event_clock); + if (renewed_stats) client_active = true; + } + bool run(uint64_t elapsed) { + mock_watchdog_millis = uint32_t(uint64_t(origin) + elapsed); + return mesh::serviceUsbLoggingWatchdog(can_recover, &safe); + } + std::string command(const char* command, size_t capacity = 160) { + struct Buffer { char reply[160]; uint8_t guard[8]; } buffer; + std::memset(&buffer, 0x5a, sizeof(buffer)); + assert(mesh::handleUsbLoggingWatchdogCommand(command, buffer.reply, capacity)); + for (uint8_t byte : buffer.guard) assert(byte == 0x5a); + assert(std::memchr(buffer.reply, 0, capacity)); + return buffer.reply; + } +}; + +static void enter_final_stage(Fixture& fixture) { + assert(!fixture.run(0)); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS - 1)); + assert(recovery_calls.empty()); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS)); + assert(recovery_calls == std::vector{1}); + assert(mesh::usbLoggingStatus().stage == 1); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS - 1)); + assert(recovery_calls.size() == 1); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS)); + assert((recovery_calls == std::vector{1, 2})); + assert(mesh::usbLoggingStatus().stage == 2); +} + +static void check_default_auto_and_preserved_choices() { + Fixture fixture; + auto status = mesh::usbLoggingStatus(); + assert(status.watchdog_auto && !status.watchdog_enabled && status.persistence_ready); + assert(status.supported && status.logging_enabled && status.backoff_step == 0); + assert(fixture.fs.files.empty() && fixture.fs.writes == 0); + assert(!fixture.run(0)); + for (uint32_t day = 1; day <= 80; ++day) assert(!fixture.run(day * 86400000ULL)); + assert(recovery_calls.empty()); // Auto never saw a reader: no recovery/reset. + assert(fixture.fs.files.empty() && fixture.fs.writes == 0); + for (uint8_t mode : {uint8_t(0), uint8_t(1), uint8_t(2)}) { + fixture.fs.files["/usb_wdg"] = image(mode, 7, 12, 34); + fixture.reload(); + status = mesh::usbLoggingStatus(); + assert(status.watchdog_enabled == (mode == 1)); + assert(status.watchdog_auto == (mode == 2)); + assert(status.backoff_step == 7 && status.retry_seconds == 128U * 3600U); + assert(status.recovery_count == 12 && status.reboot_count == 34); + assert(fixture.fs.writes == 0); + } +} + +static void check_failclosed_load_and_exact_format() { + for (unsigned fault = 0; fault != 13; ++fault) { + Fixture fixture; + auto bytes = image(1, 2, 3, 4); + switch (fault) { + case 0: bytes[0] ^= 1; break; + case 1: bytes[2] = 3; break; + case 2: bytes[3] = 1; break; + case 3: bytes[4] = 3; break; + case 4: bytes[5] = 9; break; + case 5: bytes[6] = 1; break; + case 6: bytes[7] = 1; break; + case 7: bytes[16] ^= 1; break; + case 8: bytes.pop_back(); break; + case 9: bytes.push_back(0); break; + case 10: fixture.fs.short_read = "/usb_wdg"; break; + case 11: fixture.fs.fail_open = "/usb_wdg"; break; + case 12: fixture.fs.fail_stat = "/usb_wdg"; break; + } + if (fault <= 6) repair_crc(bytes); // Reject invalid fields even with valid CRC. + fixture.fs.files["/usb_wdg"] = bytes; + fixture.fs.files["/usb_wdg.bak"] = image(1); // Not authority over bad primary. + fixture.reload(); + auto status = mesh::usbLoggingStatus(); + assert(status.watchdog_auto && !status.watchdog_enabled && !status.persistence_ready); + assert(!fixture.run(0)); + assert(!fixture.run(Policy::MAX_MS)); + assert(recovery_calls.empty()); + assert(fixture.fs.files.at("/usb_wdg") == bytes); + assert(fixture.fs.files.count("/usb_wdg.bak") == 1); + assert(fixture.fs.write_opens == 0 && fixture.fs.renames == 0 && fixture.fs.removes == 0); + // Explicit durable repair is permitted, unlike unattended recovery. + const auto reply = fixture.command("set usb.watchdog on"); + if (fault <= 9) { + assert(reply == "OK - USB watchdog on (saved)"); + assert(fixture.fs.files.at("/usb_wdg") == image(1)); + assert(mesh::usbLoggingStatus().persistence_ready); + } else { + assert(reply.find("not durable") != std::string::npos); + assert(!mesh::usbLoggingStatus().persistence_ready); + } + } +} + +static void check_backup_and_volatile_storage() { + for (unsigned fault = 0; fault != 4; ++fault) { + Fixture fixture; + const auto bytes = image(1, 8, 99, 12); + fixture.fs.files["/usb_wdg.bak"] = bytes; + if (fault == 1) fixture.fs.fail_stat = "/usb_wdg"; + if (fault == 2) { + fixture.fs.fail_rename_from = "/usb_wdg.bak"; + fixture.fs.fail_rename_to = "/usb_wdg"; + } + if (fault == 3) fixture.fs.fail_open = "/usb_wdg"; + fixture.reload(); + const auto status = mesh::usbLoggingStatus(); + assert(status.persistence_ready == (fault == 0)); + assert(status.watchdog_enabled == (fault == 0)); + if (fault == 0) { + assert(fixture.fs.files.at("/usb_wdg") == bytes); + assert(!fixture.fs.exists("/usb_wdg.bak")); + assert(status.backoff_step == 8 && status.retry_seconds == 168U * 3600U); + } + assert(fixture.fs.write_opens == 0); + } + Fixture fixture(1, 8); + fixture.reload(false); + assert(mesh::usbLoggingStatus().watchdog_auto && !mesh::usbLoggingStatus().persistence_ready); + assert(!fixture.run(0) && !fixture.run(Policy::MAX_MS)); + assert(recovery_calls.empty() && fixture.fs.write_opens == 0); + assert(fixture.command("set usb.watchdog on").find("not durable") != std::string::npos); + assert(fixture.fs.write_opens == 0); // Explicit repair cannot bless RAM storage. + mesh::loadUsbLoggingWatchdog(nullptr); + assert(!mesh::usbLoggingStatus().persistence_ready); + assert(!mesh::isUsbLoggingWatchdogArmed()); +} + +static void check_cli_is_strict_bounded_and_transactional() { + Fixture fixture; + const auto initial = fixture.command("get usb.watchdog"); + assert(initial.find("> auto ") == 0 && initial.find("usb=1 log=1 host=0") != std::string::npos); + for (const char* command : {"set usb.watchdog", "set usb.watchdog yes", + "set usb.watchdog ON", "set usb.watchdog auto reboot", + "set usb.watchdog on ", "get usb.watchdog extra"}) { + assert(fixture.command(command).find("Error: usage") == 0); + } + char reply[2] = {'x', 'x'}; + assert(!mesh::handleUsbLoggingWatchdogCommand("get usb.watchdogish", reply, sizeof(reply))); + assert(!mesh::handleUsbLoggingWatchdogCommand("set usb.watchdogs on", reply, sizeof(reply))); + assert(!mesh::handleUsbLoggingWatchdogCommand(nullptr, reply, sizeof(reply))); + assert(!mesh::handleUsbLoggingWatchdogCommand("get usb.watchdog", nullptr, 1)); + assert(!mesh::handleUsbLoggingWatchdogCommand("get usb.watchdog", reply, 0)); + assert(fixture.command("get usb.watchdog", 1).empty()); + assert(fixture.command("set usb.watchdog\ton") == "OK - USB watchdog on (saved)"); + assert(fixture.fs.files.at("/usb_wdg") == image(1)); + assert(!fixture.fs.exists("/usb_wdg.tmp") && !fixture.fs.exists("/usb_wdg.bak")); + assert(mesh::usbLoggingStatus().watchdog_enabled); + assert(fixture.command("set usb.watchdog off") == "OK - USB watchdog off (saved)"); + assert(fixture.fs.files.at("/usb_wdg") == image(0)); + assert(!mesh::usbLoggingStatus().watchdog_enabled && !mesh::usbLoggingStatus().watchdog_auto); + assert(fixture.command("set usb.watchdog auto") == "OK - USB watchdog auto (saved)"); + assert(fixture.fs.files.at("/usb_wdg") == image(2)); + assert(mesh::usbLoggingStatus().watchdog_auto); + observed.supported = false; + const auto before = fixture.fs.writes; + assert(fixture.command("set usb.watchdog on").find("unsupported") != std::string::npos); + assert(fixture.fs.writes == before); + // Dormant Auto is supported as a saved intent on an unobservable transport. + assert(fixture.command("set usb.watchdog auto") == "OK - USB watchdog auto (saved)"); +} + +static void check_failed_setter_and_readback_are_not_success() { + for (unsigned fault = 0; fault != 5; ++fault) { + Fixture fixture(0, 3, 7, 9); + const auto old = fixture.fs.files.at("/usb_wdg"); + switch (fault) { + case 0: fixture.fs.fail_write = true; break; + case 1: fixture.fs.fail_open = "/usb_wdg.tmp"; break; + case 2: + fixture.fs.fail_rename_from = "/usb_wdg.tmp"; + fixture.fs.fail_rename_to = "/usb_wdg"; + break; + case 3: fixture.fs.corrupt_final_image = true; break; + case 4: fixture.fs.fail_final_readback = true; break; + } + assert(fixture.command("set usb.watchdog on").find("not durable; unchanged") != std::string::npos); + const auto status = mesh::usbLoggingStatus(); + assert(!status.watchdog_enabled && !status.watchdog_auto && !status.persistence_ready); + assert(status.backoff_step == 3 && status.recovery_count == 7 && status.reboot_count == 9); + if (fault <= 2) assert(fixture.fs.files.at("/usb_wdg") == old); + const auto writes = fixture.fs.write_opens; + assert(!fixture.run(0) && !fixture.run(Policy::MAX_MS)); + assert(recovery_calls.empty() && fixture.fs.write_opens == writes); + fixture.fs.fail_write = false; + fixture.fs.fail_open.clear(); + fixture.fs.fail_rename_from.clear(); + fixture.fs.fail_rename_to.clear(); + fixture.fs.corrupt_final_image = false; + fixture.fs.fail_final_readback = false; + assert(fixture.command("set usb.watchdog on") == "OK - USB watchdog on (saved)"); + assert(fixture.fs.write_opens == writes + 1); // Manual retry, not automatic. + assert(fixture.fs.files.at("/usb_wdg") == image(1, 3, 7, 9)); + assert(mesh::usbLoggingStatus().persistence_ready && mesh::usbLoggingStatus().watchdog_enabled); + } +} + +static void check_stages_durable_backoff_and_saturation() { + for (uint8_t step : {uint8_t(0), uint8_t(7), uint8_t(8)}) { + Fixture fixture(1, step, 10, 20); + enter_final_stage(fixture); + auto status = mesh::usbLoggingStatus(); + assert(status.recovery_count == 12 && status.reboot_count == 20); + assert(fixture.fs.writes == 2); // Only the two attempted events commit. + const auto deadline = Policy::intervalMs(step); + assert(!fixture.run(deadline - 1)); + assert(fixture.run(deadline)); + status = mesh::usbLoggingStatus(); + const uint8_t next = Policy::nextStep(step); + assert(status.backoff_step == next && status.reboot_count == 21 && status.stage == 3); + assert(fixture.fs.files.at("/usb_wdg") == current_image(1, next, 12, 21)); + fixture.reload(); + status = mesh::usbLoggingStatus(); + assert(status.backoff_step == next && status.stage == 0); + assert(status.retry_seconds == Policy::intervalMs(next) / 1000); + } + Fixture fixture(1, 8, UINT32_MAX, UINT32_MAX); + enter_final_stage(fixture); + assert(fixture.run(Policy::MAX_MS)); + assert(fixture.fs.files.at("/usb_wdg") == current_image(1, 8, UINT32_MAX, UINT32_MAX)); + const auto maximum = fixture.command("get usb.watchdog"); + assert(maximum.size() < 160 && maximum.find("boot=4294967295") != std::string::npos); +} + +static void check_deferred_throttle_and_unsupported_backend() { + Fixture fixture(1); + recovery_result = Recovery::Deferred; + assert(!fixture.run(0)); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS)); + assert(recovery_calls == std::vector{1}); + auto status = mesh::usbLoggingStatus(); + assert(status.stage == 0 && status.recovery_count == 0 && status.recovery_deferred); + for (uint32_t delay = 1; delay < 5000; ++delay) assert(!fixture.run(Policy::EARLY_RECOVERY_MS + delay)); + assert(recovery_calls.size() == 1); + recovery_result = Recovery::Attempted; + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + 5000)); + assert((recovery_calls == std::vector{1, 1})); + assert(mesh::usbLoggingStatus().stage == 1 && mesh::usbLoggingStatus().recovery_count == 1); + assert(!mesh::usbLoggingStatus().recovery_deferred); + recovery_result = Recovery::Unsupported; + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + 5000 + Policy::RECOVERY_GRACE_MS)); + assert(!mesh::usbLoggingStatus().supported && !mesh::isUsbLoggingWatchdogArmed()); + const auto calls = recovery_calls.size(); + assert(!fixture.run(Policy::MAX_MS)); + assert(recovery_calls.size() == calls && fixture.fs.writes == 1); +} + +static void check_ownership_deferred_and_no_work_when_inactive() { + for (unsigned reason = 0; reason != 3; ++reason) { + Fixture fixture(reason == 0 ? 0 : 1); + if (reason == 1) logging_enabled = false; + if (reason == 2) observed.supported = false; + assert(!fixture.run(0)); + assert(!fixture.run(Policy::MAX_MS)); + assert(recovery_calls.empty() && probe_calls == 0 && fixture.fs.writes == 0); + } + Fixture fixture(1); + fixture.safe = false; + assert(!fixture.run(0)); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS)); + assert(recovery_calls.empty() && probe_calls == 0); + assert(mesh::usbLoggingStatus().recovery_deferred && mesh::usbLoggingStatus().stage == 0); + fixture.safe = true; + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + 1)); + assert(recovery_calls == std::vector{1}); + fixture.safe = false; + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + 1 + Policy::RECOVERY_GRACE_MS)); + assert(recovery_calls.size() == 1 && mesh::usbLoggingStatus().recovery_deferred); + fixture.safe = true; + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + 2 + Policy::RECOVERY_GRACE_MS)); + assert((recovery_calls == std::vector{1, 2})); + fixture.safe = false; + assert(!fixture.run(Policy::BASE_MS)); + assert(fixture.fs.writes == 2 && mesh::usbLoggingStatus().backoff_step == 0); + fixture.safe = true; + assert(fixture.run(Policy::BASE_MS + 1)); + assert(fixture.fs.files.at("/usb_wdg") == current_image(1, 1, 2, 1)); +} + +static void check_reboot_commit_failure_and_postcommit_veto() { + Fixture fixture(1); + enter_final_stage(fixture); + const auto old = fixture.fs.files.at("/usb_wdg"); + fixture.fs.fail_write = true; + assert(!fixture.run(Policy::BASE_MS)); + auto status = mesh::usbLoggingStatus(); + assert(!status.persistence_ready && status.stage == 2 && status.backoff_step == 0); + assert(status.reboot_count == 0 && fixture.fs.files.at("/usb_wdg") == old); + fixture.fs.fail_write = false; + const auto writes = fixture.fs.writes; + assert(!fixture.run(Policy::BASE_MS + Policy::MAX_MS)); + assert(fixture.fs.writes == writes); // A failed durable tier can never reset MCU. + + Fixture race(1); + enter_final_stage(race); + race.fs.after_final_rename = [&race]() { race.safe = false; }; + assert(!race.run(Policy::BASE_MS)); + status = mesh::usbLoggingStatus(); + assert(status.persistence_ready && status.backoff_step == 1 && status.stage == 0); + assert(status.recovery_deferred && status.reboot_count == 1); + assert(status.last_event.action == Event::REBOOT_CANCELLED && !status.last_event.persisted); + auto request = status.last_event; + request.action = Event::REBOOT_REQUESTED; + --request.sequence; + request.persisted = true; + assert(race.fs.files.at("/usb_wdg") == image(1, 1, 2, 1, &request)); + const auto pending_writes = race.fs.write_opens; + assert(!race.run(Policy::BASE_MS + 1)); + assert(race.fs.write_opens == pending_writes); // No cancellation write while unsafe. + race.safe = true; + race.fs.after_final_rename = {}; + assert(!race.run(Policy::BASE_MS + 2)); + assert(mesh::usbLoggingStatus().last_event.persisted); + assert(race.fs.files.at("/usb_wdg") == current_image(1, 1, 2, 1)); + assert(!race.run(Policy::BASE_MS + Policy::EARLY_RECOVERY_MS)); + assert(!race.run(Policy::BASE_MS + Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS)); + assert(!race.run(Policy::BASE_MS + Policy::intervalMs(1) - 1)); + assert(race.run(Policy::BASE_MS + Policy::intervalMs(1))); +} + +static void check_real_progress_not_probe_or_purge_and_master_reenable() { + Fixture fixture(1); + observed = {true, true, true, true, 0}; + assert(!fixture.run(0)); + assert(!fixture.run(Policy::STALL_OBSERVE_MS)); + assert(mesh::usbLoggingStatus().stalled); + assert(!fixture.run(60000)); + assert(probe_calls == 1 && observed.tx_progress == 0 && mesh::usbLoggingStatus().stalled); + observed.pending = false; // Purge without completion: never health evidence. + assert(!fixture.run(60001)); + assert(mesh::usbLoggingStatus().stalled); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS)); + assert(mesh::usbLoggingStatus().stage == 1); + ++observed.tx_progress; + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + 1)); + assert(!mesh::usbLoggingStatus().stalled && mesh::usbLoggingStatus().stage == 0); + assert(!fixture.run(Policy::BASE_MS)); + assert(fixture.fs.writes == 1); + observed = {true, false, false, false, 1}; + logging_enabled = false; + assert(!fixture.run(Policy::BASE_MS + 1)); + logging_enabled = true; + const uint32_t restart = Policy::BASE_MS + 2; + assert(!fixture.run(restart)); + const auto attempts = recovery_calls.size(); + assert(!fixture.run(restart + Policy::EARLY_RECOVERY_MS - 1)); + assert(recovery_calls.size() == attempts); + assert(!fixture.run(restart + Policy::EARLY_RECOVERY_MS)); + assert(recovery_calls.size() == attempts + 1); +} + +static void check_healthy_reset_is_continuous_safe_and_saved_once() { + Fixture fixture(1, 5); + observed = {true, true, true, false, 0}; + assert(!fixture.run(0)); + assert(!fixture.run(Policy::HEALTHY_RESET_MS - 1)); + fixture.safe = false; + assert(!fixture.run(Policy::HEALTHY_RESET_MS)); + assert(fixture.fs.writes == 0 && mesh::usbLoggingStatus().backoff_step == 5); + assert(mesh::usbLoggingStatus().recovery_deferred); + fixture.safe = true; + assert(!fixture.run(Policy::HEALTHY_RESET_MS + 1)); + assert(fixture.fs.files.at("/usb_wdg") == image(1)); + assert(mesh::usbLoggingStatus().backoff_step == 0); + const auto writes = fixture.fs.writes; + assert(!fixture.run(Policy::HEALTHY_RESET_MS * 2)); + assert(fixture.fs.writes == writes); + fixture.fs.files["/usb_wdg"] = image(1, 7); + fixture.reload(); + assert(!fixture.run(Policy::HEALTHY_RESET_MS * 2)); + observed.host_connected = false; + assert(!fixture.run(Policy::HEALTHY_RESET_MS * 2 + 300000)); + observed.host_connected = true; + client_active = true; // Stats polling resumes with the recovered reader. + assert(!fixture.run(Policy::HEALTHY_RESET_MS * 2 + 400000)); + assert(!fixture.run(Policy::HEALTHY_RESET_MS * 3 + 399999)); + assert(mesh::usbLoggingStatus().backoff_step == 7); + assert(!fixture.run(Policy::HEALTHY_RESET_MS * 3 + 400000)); + assert(mesh::usbLoggingStatus().backoff_step == 0); +} + +static void check_auto_promotion_continuity_and_failed_write() { + for (unsigned fault = 0; fault != 3; ++fault) { + Fixture fixture; + observed = {true, true, true, false, 0}; + assert(!fixture.run(0)); + assert(!fixture.run(AutoArm::QUALIFICATION_MS - 1)); + assert(mesh::usbLoggingStatus().watchdog_auto && fixture.fs.writes == 0); + if (fault == 1) fixture.safe = false; + if (fault == 2) fixture.fs.fail_write = true; + assert(!fixture.run(AutoArm::QUALIFICATION_MS)); + const auto status = mesh::usbLoggingStatus(); + assert(status.watchdog_enabled == (fault == 0)); + assert(status.watchdog_auto == (fault != 0)); + assert(status.persistence_ready == (fault != 2)); + assert(recovery_calls.empty()); + if (fault == 0) { + assert(fixture.fs.files.at("/usb_wdg") == image(1)); + assert(status.auto_connected_seconds == 0); + observed.host_connected = false; + assert(!fixture.run(AutoArm::QUALIFICATION_MS + 1)); + assert(!fixture.run(AutoArm::QUALIFICATION_MS + Policy::EARLY_RECOVERY_MS)); + assert(recovery_calls.empty()); + assert(!fixture.run(AutoArm::QUALIFICATION_MS + Policy::EARLY_RECOVERY_MS + 1)); + assert(recovery_calls == std::vector{1}); + } else if (fault == 1) { + assert(status.recovery_deferred && fixture.fs.writes == 0); + fixture.safe = true; + assert(!fixture.run(AutoArm::QUALIFICATION_MS + 1)); + assert(fixture.fs.files.at("/usb_wdg") == image(1)); + } else { + const auto writes = fixture.fs.writes; + assert(!fixture.run(AutoArm::QUALIFICATION_MS + Policy::MAX_MS)); + assert(fixture.fs.writes == writes && recovery_calls.empty()); + } + } + for (unsigned reason = 0; reason != 4; ++reason) { + Fixture fixture; + observed = {true, true, true, false, 0}; + assert(!fixture.run(0)); + assert(!fixture.run(AutoArm::QUALIFICATION_MS - 1)); + if (reason == 0) observed.host_connected = false; + if (reason == 1) observed.reader_connected = false; + if (reason == 2) logging_enabled = false; + if (reason == 3) observed.supported = false; + assert(!fixture.run(AutoArm::QUALIFICATION_MS)); + assert(mesh::usbLoggingStatus().auto_connected_seconds == 0); + logging_enabled = true; + observed = {true, true, true, false, 0}; + client_active = true; // Reopened application resumes its normal stats polls. + const uint64_t restart = uint64_t(AutoArm::QUALIFICATION_MS) + 1; + assert(!fixture.run(restart)); + assert(!fixture.run(restart + AutoArm::QUALIFICATION_MS - 1)); + assert(mesh::usbLoggingStatus().watchdog_auto); + assert(!fixture.run(restart + AutoArm::QUALIFICATION_MS)); + assert(mesh::usbLoggingStatus().watchdog_enabled); + } + Fixture previous_tier(2, 8, 17, 29); + observed = {true, true, true, false, 0}; + assert(!previous_tier.run(0)); + assert(!previous_tier.run(Policy::HEALTHY_RESET_MS)); + assert(previous_tier.fs.writes == 0 && mesh::usbLoggingStatus().backoff_step == 8); + assert(!previous_tier.run(AutoArm::QUALIFICATION_MS)); + assert(previous_tier.fs.files.at("/usb_wdg") == image(1, 0, 17, 29)); + assert(mesh::usbLoggingStatus().watchdog_enabled && mesh::usbLoggingStatus().backoff_step == 0); +} + +static void check_auto_seen_reader_usb_only_and_probe_is_not_health() { + Fixture fixture; + observed = {true, true, true, false, 0}; + assert(!fixture.run(0)); + observed.host_connected = observed.reader_connected = false; + assert(!fixture.run(1)); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + 1)); + assert(recovery_calls == std::vector{1}); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS + 1)); + assert((recovery_calls == std::vector{1, 2})); + for (uint32_t day = 1; day <= 80; ++day) assert(!fixture.run(day * 86400000ULL)); + assert(recovery_calls.size() == 2 && fixture.fs.writes == 2); + assert(mesh::usbLoggingStatus().watchdog_auto && !mesh::usbLoggingStatus().watchdog_enabled); + assert(mesh::usbLoggingStatus().backoff_step == 0 && mesh::usbLoggingStatus().reboot_count == 0); + + Fixture stalled; + observed = {true, true, true, false, 0}; + probe_adds_pending = true; + assert(!stalled.run(0)); + assert(!stalled.run(60000)); + assert(probe_calls == 1 && observed.pending && observed.tx_progress == 0); + assert(!stalled.run(60001)); + assert(!stalled.run(90001)); + assert(mesh::usbLoggingStatus().stalled && mesh::usbLoggingStatus().auto_connected_seconds == 0); + observed.pending = false; + assert(!stalled.run(90002)); + assert(mesh::usbLoggingStatus().stalled && mesh::usbLoggingStatus().auto_connected_seconds == 0); + probe_adds_pending = false; + observed.tx_progress = 1; + assert(!stalled.run(90003)); + assert(!mesh::usbLoggingStatus().stalled); + assert(!stalled.run(uint64_t(90003) + AutoArm::QUALIFICATION_MS - 1)); + assert(mesh::usbLoggingStatus().watchdog_auto); + assert(!stalled.run(uint64_t(90003) + AutoArm::QUALIFICATION_MS)); + assert(mesh::usbLoggingStatus().watchdog_enabled); +} + +static void check_millis_rollover_and_restart_requalifies_auto() { + Fixture fixture(1, 0, 0, 0, UINT32_MAX - 1500); + enter_final_stage(fixture); + assert(!fixture.run(Policy::BASE_MS - 1)); + assert(fixture.run(Policy::BASE_MS)); + assert(fixture.fs.files.at("/usb_wdg") == current_image(1, 1, 2, 1)); + Fixture automatic(2, 0, 0, 0, UINT32_MAX - 1500); + observed = {true, true, true, false, 0}; + assert(!automatic.run(0)); + assert(!automatic.run(AutoArm::QUALIFICATION_MS - 1)); + automatic.reload(); // Reboot cannot prove a reader remained connected. + assert(mesh::usbLoggingStatus().auto_connected_seconds == 0); + assert(!automatic.run(AutoArm::QUALIFICATION_MS - 1)); + assert(!automatic.run(uint64_t(AutoArm::QUALIFICATION_MS) * 2 - 2)); + assert(mesh::usbLoggingStatus().watchdog_auto); + assert(!automatic.run(uint64_t(AutoArm::QUALIFICATION_MS) * 2 - 1)); + assert(mesh::usbLoggingStatus().watchdog_enabled); +} + +static void check_pending_attach_keeps_service_hint_after_disable() { + Fixture fixture(1); + assert(mesh::isUsbLoggingWatchdogArmed()); + recovery_pending = true; + assert(fixture.command("set usb.watchdog off") == "OK - USB watchdog off (saved)"); + logging_enabled = false; + assert(mesh::isUsbLoggingWatchdogArmed()); // Finish descriptor-preserving attach. + assert(!fixture.run(0)); + assert(recovery_calls.empty()); + recovery_pending = false; + assert(!mesh::isUsbLoggingWatchdogArmed()); +} + +static void check_stats_lease_gates_auto_not_physical_on_mode() { + Fixture unattended; + observed = {true, true, true, false, 0}; + client_active = false; // Plugged USB and DTR do not identify a logging client. + assert(!unattended.run(0)); + for (uint32_t day = 1; day <= 21; ++day) { + ++observed.tx_progress; // Even kernel USB completions are not stats polls. + assert(!unattended.run(day * 86400000ULL)); + const auto status = mesh::usbLoggingStatus(); + assert(status.host_connected && status.reader_connected && !status.logger_active); + assert(status.watchdog_auto && status.auto_connected_seconds == 0 && status.stage == 0); + } + assert(recovery_calls.empty() && probe_calls == 0 && unattended.fs.writes == 0); + + Fixture active; + observed = {true, true, true, false, 0}; + assert(!active.run(0)); + assert(!active.run(mesh::USB_LOGGING_CLIENT_LEASE_MS - 1)); + assert(mesh::usbLoggingStatus().logger_active); + client_active = false; + const uint32_t expired = mesh::USB_LOGGING_CLIENT_LEASE_MS; + assert(!active.run(expired)); + assert(mesh::usbLoggingStatus().auto_connected_seconds == 0); + // ACKs without application polls cannot keep delaying Auto's USB-only repair. + for (uint32_t elapsed = 10000; elapsed < Policy::EARLY_RECOVERY_MS; elapsed += 10000) { + ++observed.tx_progress; + assert(!active.run(expired + elapsed)); + assert(recovery_calls.empty()); + } + ++observed.tx_progress; + assert(!active.run(expired + Policy::EARLY_RECOVERY_MS)); + assert(recovery_calls == std::vector{1}); + ++observed.tx_progress; + assert(!active.run(expired + Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS)); + assert((recovery_calls == std::vector{1, 2})); + const uint32_t renewed = 3U * 86400000U; + assert(!active.run(renewed)); + assert(active.fs.writes == 2 && mesh::usbLoggingStatus().reboot_count == 0); + client_active = true; + ++observed.tx_progress; + assert(!active.run(renewed + 1)); + assert(mesh::usbLoggingStatus().logger_active && mesh::usbLoggingStatus().stage == 0); + assert(!active.run(uint64_t(renewed) + AutoArm::QUALIFICATION_MS)); + assert(mesh::usbLoggingStatus().watchdog_auto); + assert(!active.run(uint64_t(renewed) + AutoArm::QUALIFICATION_MS + 1)); + assert(active.fs.files.at("/usb_wdg") == current_image(1, 0, 2, 0)); + // Promotion retains the proven stats reader. Its later absence can repair + // USB, but healthy physical USB alone cannot authorize an MCU reset. + client_active = false; + ++observed.tx_progress; + const uint64_t stopped = uint64_t(renewed) + AutoArm::QUALIFICATION_MS + Policy::BASE_MS; + assert(!active.run(stopped)); + assert(mesh::usbLoggingStatus().watchdog_enabled && !mesh::usbLoggingStatus().logger_active); + assert(mesh::usbLoggingStatus().stage == 0 && mesh::usbLoggingStatus().reboot_count == 0); + ++observed.tx_progress; + assert(!active.run(stopped + Policy::EARLY_RECOVERY_MS)); + assert(mesh::usbLoggingStatus().stage == 1 && recovery_calls.size() == 3); + ++observed.tx_progress; + assert(!active.run(stopped + Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS)); + assert(mesh::usbLoggingStatus().stage == 2 && recovery_calls.size() == 4); + for (uint64_t day = 1; day <= 80; ++day) { + ++observed.tx_progress; + assert(!active.run(stopped + day * 86400000ULL)); + assert(mesh::usbLoggingStatus().reboot_count == 0); + } + assert(active.fs.files.at("/usb_wdg") == current_image(1, 0, 4, 0)); + + Fixture non_stats_reader(1); + observed = {true, true, true, false, 0}; + client_active = false; + assert(!non_stats_reader.run(0)); + for (uint64_t day = 1; day <= 21; ++day) { + ++observed.tx_progress; + assert(!non_stats_reader.run(day * 86400000ULL)); + assert(mesh::usbLoggingStatus().watchdog_enabled && mesh::usbLoggingStatus().stage == 0); + } + assert(recovery_calls.empty() && non_stats_reader.fs.writes == 0); +} + +static void check_lease_clears_on_load_master_off_and_disconnect() { + Fixture fixture; + assert(client_clear_calls == 1); + observed = {true, true, true, false, 0}; + assert(!fixture.run(0)); + assert(mesh::usbLoggingStatus().logger_active); + fixture.reload(true, false); + assert(!client_active && client_clear_calls == 2); + assert(!fixture.run(1)); + assert(!mesh::usbLoggingStatus().logger_active); + assert(mesh::usbLoggingStatus().auto_connected_seconds == 0); + client_active = true; + assert(!fixture.run(2)); + assert(mesh::usbLoggingStatus().logger_active); + logging_enabled = false; + assert(!fixture.run(3)); + assert(!client_active && client_clear_calls == 3); + logging_enabled = true; + client_active = true; + observed.host_connected = false; + assert(!fixture.run(4)); + assert(!client_active && client_clear_calls == 4); + observed.host_connected = true; + client_active = true; + observed.reader_connected = false; + assert(!fixture.run(5)); + assert(!client_active && client_clear_calls == 5); + observed.reader_connected = true; + assert(!fixture.run(6)); + assert(!mesh::usbLoggingStatus().logger_active); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + 6)); + assert(recovery_calls.empty()); // Master OFF deliberately forgets the old client. + client_active = true; + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + 7)); + assert(mesh::usbLoggingStatus().logger_active); + client_active = false; + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + 8)); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS * 2 + 8)); + // A freshly proven stats client is remembered for USB-only outage repair. + assert(recovery_calls == std::vector{1}); +} + +static void check_commit_time_progress_cancels_board_reset() { + Fixture fixture(1); + enter_final_stage(fixture); + fixture.fs.after_final_rename = [] { + observed = {true, true, true, false, 1}; // Actual ACK/reconnected transport. + client_active = true; + }; + assert(!fixture.run(Policy::BASE_MS)); + const auto status = mesh::usbLoggingStatus(); + assert(status.persistence_ready && status.backoff_step == 1); + assert(status.stage == 0 && !status.recovery_deferred && status.inactive_seconds == 0); + assert(fixture.fs.files.at("/usb_wdg") == current_image(1, 1, 2, 1)); + assert(!fixture.run(Policy::BASE_MS + 1)); +} + +static void check_app_outage_cannot_consume_physical_reboot_deadline() { + for (bool stalled_tx : {false, true}) { + Fixture fixture(1); + observed = {true, true, true, false, 0}; + assert(!fixture.run(0)); + client_active = false; + const uint32_t expired = mesh::USB_LOGGING_CLIENT_LEASE_MS; + assert(!fixture.run(expired)); + ++observed.tx_progress; + assert(!fixture.run(expired + Policy::EARLY_RECOVERY_MS)); + ++observed.tx_progress; + assert(!fixture.run(expired + Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS)); + assert(mesh::usbLoggingStatus().stage == 2); + const uint32_t physical_fault = 2U * 86400000U; + ++observed.tx_progress; + assert(!fixture.run(physical_fault)); + assert(fixture.fs.writes == 2); // App-only loss saved USB attempts, not a board reset. + if (stalled_tx) observed.pending = true; + else observed.host_connected = observed.reader_connected = false; + assert(!fixture.run(physical_fault)); + assert(!fixture.run(physical_fault + Policy::STALL_OBSERVE_MS)); + assert(!fixture.run(physical_fault + Policy::BASE_MS - 1)); + assert(fixture.fs.writes == 2 && mesh::usbLoggingStatus().reboot_count == 0); + assert(fixture.run(physical_fault + Policy::BASE_MS)); + assert(fixture.fs.files.at("/usb_wdg") == current_image(1, 1, 2, 1)); + } +} + +static void check_legacy_state_lazy_upgrade_and_backup_length() { + for (uint8_t mode : {uint8_t(0), uint8_t(1), uint8_t(2)}) { + Fixture fixture; + const auto legacy = image(mode, 8, 19, 23, nullptr, true); + fixture.fs.files["/usb_wdg"] = legacy; + fixture.reload(); + const auto status = mesh::usbLoggingStatus(); + assert(status.persistence_ready && status.backoff_step == 8); + assert(status.watchdog_enabled == (mode == 1) && status.watchdog_auto == (mode == 2)); + assert(status.recovery_count == 19 && status.reboot_count == 23); + assert(status.last_event.sequence == 0 && !status.last_event.persisted); + assert(fixture.fs.write_opens == 0 && fixture.fs.files.at("/usb_wdg") == legacy); + const char* command = mode == 0 ? "set usb.watchdog off" + : mode == 1 ? "set usb.watchdog on" : "set usb.watchdog auto"; + assert(fixture.command(command).find("(saved)") != std::string::npos); + assert(fixture.fs.files.at("/usb_wdg") == image(mode, 8, 19, 23)); + assert(fixture.fs.write_opens == 1); + } + for (bool legacy : {false, true}) { + Fixture fixture; + const auto saved = image(1, 7, 8, 9, nullptr, legacy); + fixture.fs.files["/usb_wdg.bak"] = saved; + fixture.reload(); + assert(mesh::usbLoggingStatus().persistence_ready); + assert(fixture.fs.files.at("/usb_wdg") == saved && fixture.fs.write_opens == 0); + } +} + +static void check_event_persistence_latest_only_and_clock_history() { + Fixture fixture(1); + event_clock = 1900000123; + assert(!fixture.run(0)); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS)); + auto event = mesh::usbLoggingStatus().last_event; + assert(event.action == Event::SOFT_RECOVERY && event.reasons == 3 && event.sequence == 1); + assert(event.epoch == event_clock && event.uptime_seconds == 300 && event.persisted); + assert(fixture.fs.files.at("/usb_wdg") == current_image(1, 0, 1, 0)); + const auto writes = fixture.fs.write_opens; + for (uint32_t tick = 1; tick != 10; ++tick) { + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + tick)); + assert(fixture.fs.write_opens == writes); // Probes and repeated polls are not events. + } + event_clock = 1800000456; // A corrected node clock may move backward. + assert(!fixture.run(Policy::EARLY_RECOVERY_MS + Policy::RECOVERY_GRACE_MS)); + event = mesh::usbLoggingStatus().last_event; + assert(event.action == Event::REENUMERATE && event.sequence == 2 && event.epoch == event_clock); + assert(event.uptime_seconds == 360 && event.persisted && fixture.fs.files.size() == 1); + const auto saved = fixture.fs.files.at("/usb_wdg"); + mock_watchdog_millis = 100; // Reboot: historical event uptime is not this boot's uptime. + fixture.reload(); + event = mesh::usbLoggingStatus().last_event; + assert(event.sequence == 2 && event.uptime_seconds == 360 && event.epoch == event_clock); + assert(fixture.fs.files.at("/usb_wdg") == saved && fixture.fs.write_opens == writes + 1); + assert(fixture.command("get usb.watchdog.last").find( + "> reenumerate reasons=0x03 epoch=1800000456 uptime=360s seq=2 persisted=1") == 0); + + Fixture no_clock(1); + mesh::loadUsbLoggingWatchdog(&no_clock.fs); // Missing callback must not invent UTC. + assert(!no_clock.run(0) && !no_clock.run(Policy::EARLY_RECOVERY_MS)); + assert(mesh::usbLoggingStatus().last_event.epoch == 0); + Fixture wrap(1, 0, 0, 0, UINT32_MAX - 1500); + assert(!wrap.run(0) && !wrap.run(Policy::EARLY_RECOVERY_MS)); + assert(mesh::usbLoggingStatus().last_event.uptime_seconds + == (uint64_t(wrap.origin) + Policy::EARLY_RECOVERY_MS) / 1000); + + Fixture off(0, 0, 0, 0, UINT32_MAX - 1500); + assert(!off.run(Policy::EARLY_RECOVERY_MS)); // Off still extends the uptime clock. + assert(off.command("set usb.watchdog on").find("(saved)") != std::string::npos); + assert(!off.run(Policy::EARLY_RECOVERY_MS + 1)); + assert(!off.run(Policy::EARLY_RECOVERY_MS * 2 + 1)); + assert(mesh::usbLoggingStatus().last_event.uptime_seconds + == (uint64_t(off.origin) + Policy::EARLY_RECOVERY_MS * 2 + 1) / 1000); +} + +static void check_event_reasons_failclosed_and_manual_repair() { + Fixture app; + observed = {true, true, true, false, 0}; + assert(!app.run(0)); + client_active = false; + assert(!app.run(1) && !app.run(Policy::EARLY_RECOVERY_MS + 1)); + assert(mesh::usbLoggingStatus().last_event.reasons == Event::CLIENT_INACTIVE); + + Fixture tx(1); + observed = {true, true, true, true, 0}; + assert(!tx.run(0) && !tx.run(Policy::STALL_OBSERVE_MS)); + assert(!tx.run(Policy::EARLY_RECOVERY_MS)); + assert(mesh::usbLoggingStatus().last_event.reasons == Event::TX_STALLED); + + Fixture failing(1); + const auto old = failing.fs.files.at("/usb_wdg"); + failing.fs.fail_write = true; + assert(!failing.run(0) && !failing.run(Policy::EARLY_RECOVERY_MS)); + const auto status = mesh::usbLoggingStatus(); + assert(status.last_event.action == Event::SOFT_RECOVERY && !status.last_event.persisted); + assert(!status.persistence_ready && status.recovery_count == 1); + assert(failing.fs.files.at("/usb_wdg") == old); + const auto attempts = recovery_calls.size(); + const auto writes = failing.fs.write_opens; + failing.fs.fail_write = false; + assert(!failing.run(Policy::MAX_MS)); + assert(recovery_calls.size() == attempts && failing.fs.write_opens == writes); + assert(failing.command("set usb.watchdog on").find("(saved)") != std::string::npos); + assert(mesh::usbLoggingStatus().last_event.persisted); + assert(failing.fs.files.at("/usb_wdg") == current_image(1, 0, 1, 0)); +} + +static void check_bad_event_images_are_not_fresh_state() { + for (unsigned fault = 0; fault != 8; ++fault) { + Fixture fixture; + Event event; + event.reasons = Event::TX_STALLED; + event.action = Event::SOFT_RECOVERY; + event.sequence = 7; + event.persisted = true; + auto bytes = image(1, 2, 3, 4, &event); + switch (fault) { + case 0: bytes[16] &= 0xf0; break; // No reason. + case 1: bytes[16] &= 0x8f; break; // NONE action with nonzero sequence. + case 2: bytes[16] = 0xd4; break; // Unknown action 5. + case 3: bytes[16] &= 0x7f; break; // Disk cannot claim an unpersisted event. + case 4: mesh::storage::writeLE32(bytes.data() + 25, 0); break; + case 5: bytes[2] = 1; break; // UW1 must have the old exact size. + case 6: bytes.resize(20); break; // UW2 must have the new exact size. + case 7: bytes[17] ^= 1; break; // Event covered by CRC. + } + if (fault != 7) repair_crc(bytes); + fixture.fs.files["/usb_wdg"] = bytes; + fixture.fs.files["/usb_wdg.bak"] = image(1); + fixture.reload(); + const auto status = mesh::usbLoggingStatus(); + assert(!status.persistence_ready && status.last_event.sequence == 0); + assert(!fixture.run(0) && !fixture.run(Policy::MAX_MS)); + assert(recovery_calls.empty() && fixture.fs.write_opens == 0 && fixture.fs.renames == 0); + assert(fixture.fs.files.at("/usb_wdg") == bytes); + } +} + +static void check_pending_cancellation_flush_ignores_logging_and_backend() { + for (bool unavailable_backend : {false, true}) { + Fixture fixture(1); + enter_final_stage(fixture); + fixture.fs.after_final_rename = [&fixture] { fixture.safe = false; }; + assert(!fixture.run(Policy::BASE_MS)); + assert(mesh::usbLoggingStatus().last_event.action == Event::REBOOT_CANCELLED); + assert(!mesh::usbLoggingStatus().last_event.persisted); + const auto writes = fixture.fs.write_opens; + const auto probes = probe_calls; + const auto recoveries = recovery_calls.size(); + logging_enabled = false; + if (unavailable_backend) observed.supported = false; + assert(!fixture.run(Policy::BASE_MS + 1)); + assert(fixture.fs.write_opens == writes); + fixture.safe = true; + fixture.fs.after_final_rename = {}; + assert(!fixture.run(Policy::BASE_MS + 2)); + assert(mesh::usbLoggingStatus().last_event.persisted); + assert(fixture.fs.write_opens == writes + 1); + assert(fixture.fs.files.at("/usb_wdg") == current_image(1, 1, 2, 1)); + assert(!fixture.run(Policy::BASE_MS + 3)); + assert(fixture.fs.write_opens == writes + 1); + assert(probe_calls == probes && recovery_calls.size() == recoveries); + } + Fixture failed(1); + enter_final_stage(failed); + failed.fs.after_final_rename = [&failed] { failed.safe = false; }; + assert(!failed.run(Policy::BASE_MS)); + failed.safe = true; + failed.fs.after_final_rename = {}; + failed.fs.fail_write = true; + assert(!failed.run(Policy::BASE_MS + 1)); + assert(!mesh::usbLoggingStatus().persistence_ready); + assert(!mesh::usbLoggingStatus().last_event.persisted); + const auto writes = failed.fs.write_opens; + failed.fs.fail_write = false; + assert(!failed.run(Policy::BASE_MS + Policy::MAX_MS)); + assert(failed.fs.write_opens == writes); // No automatic retry after failed commit. + assert(failed.command("set usb.watchdog off").find("(saved)") != std::string::npos); + assert(mesh::usbLoggingStatus().last_event.persisted); + assert(failed.fs.files.at("/usb_wdg") == current_image(0, 1, 2, 1)); +} + +static void check_event_cli_is_strict_and_bounded() { + Fixture fixture; + Event event; + event.action = Event::REBOOT_CANCELLED; + event.reasons = 15; + event.epoch = event.uptime_seconds = event.sequence = UINT32_MAX; + event.persisted = true; + fixture.fs.files["/usb_wdg"] = image(1, 8, UINT32_MAX, UINT32_MAX, &event); + fixture.reload(); + const auto reply = fixture.command("get usb.watchdog.last"); + assert(reply == "> reboot-cancelled reasons=0x0F epoch=4294967295 uptime=4294967295s seq=4294967295 persisted=1"); + assert(reply.size() < 160); + for (size_t capacity = 0; capacity <= 160; ++capacity) { + unsigned char buffer[168]; + memset(buffer, 0x5a, sizeof(buffer)); + const bool handled = mesh::handleUsbLoggingWatchdogCommand("get usb.watchdog.last", + reinterpret_cast(buffer), capacity); + assert(handled == (capacity != 0)); + if (capacity) assert(memchr(buffer, 0, capacity)); + for (size_t i = capacity; i < sizeof(buffer); ++i) assert(buffer[i] == 0x5a); + } + char buffer[160]; + assert(!mesh::handleUsbLoggingWatchdogCommand("get usb.watchdog.lastx", buffer, sizeof(buffer))); + assert(!mesh::handleUsbLoggingWatchdogCommand("set usb.watchdog.last on", buffer, sizeof(buffer))); + assert(fixture.command("get usb.watchdog.last extra") == "Error: usage get usb.watchdog.last"); + assert(fixture.command("set usb.watchdog off").find("(saved)") != std::string::npos); + assert(mesh::usbLoggingStatus().last_event.sequence == UINT32_MAX); // Setter preserves history. +} + +static void check_event_snapshot_is_coherent_with_concurrent_readers() { + Fixture fixture(1); + std::atomic running{true}; + std::atomic snapshots{0}; + std::thread reader([&] { + while (running.load()) { + const auto event = mesh::usbLoggingStatus().last_event; + if (event.sequence) { + assert(event.epoch == 2000000000U + event.sequence); + assert(event.action == Event::SOFT_RECOVERY && event.reasons == 3 && event.persisted); + } + ++snapshots; + } + }); + uint64_t now = 0; + for (uint32_t sequence = 1; sequence <= 200; ++sequence) { + observed = {true, false, false, false, sequence}; + assert(!fixture.run(now)); + event_clock = 2000000000U + sequence; + now += Policy::EARLY_RECOVERY_MS; + assert(!fixture.run(now)); + observed = {true, true, true, false, sequence + 1}; + client_active = true; + assert(!fixture.run(++now)); + ++now; + } + running = false; + reader.join(); + assert(snapshots.load() && mesh::usbLoggingStatus().last_event.sequence == 200); +} + +#if defined(ENABLE_OTA) +static void check_actual_update_activity_guard() { + assert(!mesh::isUsbLoggingWatchdogUpdateActive()); + for (unsigned reason = 0; reason != 9; ++reason) { + Fixture fixture(1); + mesh::ota::OtaContext ota; + ota.serving = true; // Normal idle self-firmware advertising is not activity. + mesh::ota::test_ota_context = &ota; + assert(!mesh::isUsbLoggingWatchdogUpdateActive()); + switch (reason) { + case 0: ota.apply_pending = true; break; + case 1: ota.bootloader_apply_pending = true; break; + case 2: ota.folder_active = true; break; + case 3: ota.folder_dest = true; break; + case 4: ota.capture_waiting = true; break; + case 5: ota.manager.serve_jobs = 1; break; + case 6: ota.manager.manifest_jobs = 1; break; + case 7: ota.serve_expected = true; break; + case 8: ota.manager.state = mesh::ota::OtaManager::FETCHING; break; + } + assert(mesh::isUsbLoggingWatchdogUpdateActive()); + assert(!fixture.run(0)); + assert(!fixture.run(Policy::EARLY_RECOVERY_MS)); + assert(!fixture.run(Policy::BASE_MS)); + assert(recovery_calls.empty() && probe_calls == 0 && fixture.fs.writes == 0); + assert(mesh::usbLoggingStatus().stage == 0 && mesh::usbLoggingStatus().recovery_deferred); + ota = {}; + ota.serving = true; + assert(!mesh::isUsbLoggingWatchdogUpdateActive()); + assert(!fixture.run(Policy::BASE_MS + 1)); + assert(recovery_calls == std::vector{1}); + ota.manager.manifest_jobs = 1; + assert(!fixture.run(Policy::BASE_MS + 1 + Policy::RECOVERY_GRACE_MS)); + assert(recovery_calls.size() == 1 && fixture.fs.writes == 1); + ota.manager.manifest_jobs = 0; + assert(!fixture.run(Policy::BASE_MS + 2 + Policy::RECOVERY_GRACE_MS)); + assert((recovery_calls == std::vector{1, 2})); + ota.manager.serve_jobs = 1; + assert(!fixture.run(Policy::BASE_MS + 2 + Policy::RECOVERY_GRACE_MS * 2)); + assert(mesh::usbLoggingStatus().stage == 2 && fixture.fs.writes == 2); + ota.manager.serve_jobs = 0; + assert(fixture.run(Policy::BASE_MS + 3 + Policy::RECOVERY_GRACE_MS * 2)); + assert(fixture.fs.files.at("/usb_wdg") == current_image(1, 1, 2, 1)); + ota.manager.state = mesh::ota::OtaManager::FAILED; + assert(!mesh::isUsbLoggingWatchdogUpdateActive()); + ota.manager.state = mesh::ota::OtaManager::COMPLETE; + assert(mesh::isUsbLoggingWatchdogUpdateActive()); + mesh::ota::test_ota_context = nullptr; + } + Fixture automatic; + mesh::ota::OtaContext ota; + ota.folder_active = true; + mesh::ota::test_ota_context = &ota; + observed = {true, true, true, false, 0}; + assert(!automatic.run(0)); + assert(!automatic.run(AutoArm::QUALIFICATION_MS)); + assert(automatic.fs.writes == 0 && mesh::usbLoggingStatus().watchdog_auto); + ota.folder_active = false; + assert(!automatic.run(AutoArm::QUALIFICATION_MS + 1)); + assert(automatic.fs.files.at("/usb_wdg") == image(1)); + mesh::ota::test_ota_context = nullptr; +} +#endif + +int main() { + setbuf(stdout, nullptr); + check_default_auto_and_preserved_choices(); + check_failclosed_load_and_exact_format(); + check_backup_and_volatile_storage(); + check_cli_is_strict_bounded_and_transactional(); + check_failed_setter_and_readback_are_not_success(); + check_stages_durable_backoff_and_saturation(); + check_deferred_throttle_and_unsupported_backend(); + check_ownership_deferred_and_no_work_when_inactive(); + check_reboot_commit_failure_and_postcommit_veto(); + check_real_progress_not_probe_or_purge_and_master_reenable(); + check_healthy_reset_is_continuous_safe_and_saved_once(); + check_auto_promotion_continuity_and_failed_write(); + check_auto_seen_reader_usb_only_and_probe_is_not_health(); + check_millis_rollover_and_restart_requalifies_auto(); + check_pending_attach_keeps_service_hint_after_disable(); + check_stats_lease_gates_auto_not_physical_on_mode(); + check_lease_clears_on_load_master_off_and_disconnect(); + check_commit_time_progress_cancels_board_reset(); + check_app_outage_cannot_consume_physical_reboot_deadline(); + check_legacy_state_lazy_upgrade_and_backup_length(); + check_event_persistence_latest_only_and_clock_history(); + check_event_reasons_failclosed_and_manual_repair(); + check_bad_event_images_are_not_fresh_state(); + check_pending_cancellation_flush_ignores_logging_and_backend(); + check_event_cli_is_strict_and_bounded(); + check_event_snapshot_is_coherent_with_concurrent_readers(); +#if defined(ENABLE_OTA) + check_actual_update_activity_guard(); +#endif + puts("USB watchdog runtime and persistence passed"); +} diff --git a/test/test_common_radio_persistence.py b/test/test_common_radio_persistence.py index f8c201276..3c690c81e 100644 --- a/test/test_common_radio_persistence.py +++ b/test/test_common_radio_persistence.py @@ -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); diff --git a/test/test_common_usb_debug.py b/test/test_common_usb_debug.py new file mode 100644 index 000000000..869a0d91a --- /dev/null +++ b/test/test_common_usb_debug.py @@ -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 +#include +#include +#include +#include +#include +#include +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 +#include +#include +#include +#include +#include +#include +#include +#define MIN_LORA_TX_POWER -9 +#define MAX_LORA_TX_POWER 22 +#include +#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 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 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 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\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 ')) + (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() diff --git a/test/test_companion_bluetooth_persistence.py b/test/test_companion_bluetooth_persistence.py index d5026466c..fe1666084 100644 --- a/test/test_companion_bluetooth_persistence.py +++ b/test/test_companion_bluetooth_persistence.py @@ -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)); diff --git a/test/test_companion_identity_cli.py b/test/test_companion_identity_cli.py new file mode 100644 index 000000000..d3bcc8753 --- /dev/null +++ b/test/test_companion_identity_cli.py @@ -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 +#include +#include +#include +#include +#include +#include + +namespace mesh { +@HEX_CHARS@ +@TO_HEX@ +@IDENTITY_METHODS@ +} + +class IdentityInput : public Stream { +public: + std::vector bytes; + size_t offset=0; + int available() override{return int(bytes.size()-offset);} + int read() override{return offset snapshot(const MyMesh& node){ + std::array 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 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 guarded(size+16,0xa7); + char* reply=reinterpret_cast(guarded.data()+8); + const unsigned fallback=node.fallback_calls; + assert(node.handleLocalControlCommand("get public.key",reply,size)); + const size_t written=size-1 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() diff --git a/test/test_companion_preferences_transaction.py b/test/test_companion_preferences_transaction.py index 27b519890..2b549a6f9 100644 --- a/test/test_companion_preferences_transaction.py +++ b/test/test_companion_preferences_transaction.py @@ -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); diff --git a/test/test_companion_prefs_transactions.py b/test/test_companion_prefs_transactions.py index d465a2d3e..1bcd624a4 100644 --- a/test/test_companion_prefs_transactions.py +++ b/test/test_companion_prefs_transactions.py @@ -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& 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 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(', diff --git a/test/test_companion_usb_logging.py b/test/test_companion_usb_logging.py new file mode 100644 index 000000000..079e13b27 --- /dev/null +++ b/test/test_companion_usb_logging.py @@ -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(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(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 ")); + 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+1setByKey("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(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 +#include +#include +#include +#include +#include +#include +std::vector 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(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> 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> 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 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(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 +#include +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 +#include +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 ')) + + 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 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 +#include +#include +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 \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 (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() diff --git a/test/test_esp32_tinyusb_cooperative_output.py b/test/test_esp32_tinyusb_cooperative_output.py index 72067642f..168ea70b7 100644 --- a/test/test_esp32_tinyusb_cooperative_output.py +++ b/test/test_esp32_tinyusb_cooperative_output.py @@ -24,7 +24,7 @@ HARNESS = r''' #include #include #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] diff --git a/test/test_esp32_tinyusb_nonblocking.py b/test/test_esp32_tinyusb_nonblocking.py index 9138a12e9..aa6c1edb5 100644 --- a/test/test_esp32_tinyusb_nonblocking.py +++ b/test/test_esp32_tinyusb_nonblocking.py @@ -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: diff --git a/test/test_esp32_tinyusb_role_hygiene.py b/test/test_esp32_tinyusb_role_hygiene.py index 22278737b..6f54fe9a8 100644 --- a/test/test_esp32_tinyusb_role_hygiene.py +++ b/test/test_esp32_tinyusb_role_hygiene.py @@ -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: diff --git a/test/test_esp32_usb_serial_hygiene.py b/test/test_esp32_usb_serial_hygiene.py index 4be24a617..dfa0b221e 100644 --- a/test/test_esp32_usb_serial_hygiene.py +++ b/test/test_esp32_usb_serial_hygiene.py @@ -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): diff --git a/test/test_management_decoder.js b/test/test_management_decoder.js index 3e8592b9b..9a89d65dd 100644 --- a/test/test_management_decoder.js +++ b/test/test_management_decoder.js @@ -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); diff --git a/test/test_management_report.py b/test/test_management_report.py index 37d55897c..53e6b8dee 100644 --- a/test/test_management_report.py +++ b/test/test_management_report.py @@ -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)) diff --git a/test/test_nrf52_debug_output/test_nrf52_debug_output.cpp b/test/test_nrf52_debug_output/test_nrf52_debug_output.cpp index 7f2895da1..be9ec0cbc 100644 --- a/test/test_nrf52_debug_output/test_nrf52_debug_output.cpp +++ b/test/test_nrf52_debug_output/test_nrf52_debug_output.cpp @@ -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; diff --git a/test/test_nrf52_usb_client.py b/test/test_nrf52_usb_client.py index d8bafcc99..5211a2606 100644 --- a/test/test_nrf52_usb_client.py +++ b/test/test_nrf52_usb_client.py @@ -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 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 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) diff --git a/test/test_nrf52_usb_console.py b/test/test_nrf52_usb_console.py new file mode 100644 index 000000000..5155dacc4 --- /dev/null +++ b/test/test_nrf52_usb_console.py @@ -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() diff --git a/test/test_nrf52_usb_logging_contract.py b/test/test_nrf52_usb_logging_contract.py index 96af98102..8f745eb09 100644 --- a/test/test_nrf52_usb_logging_contract.py +++ b/test/test_nrf52_usb_logging_contract.py @@ -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) diff --git a/test/test_trace_retry/test_trace_retry.cpp b/test/test_trace_retry/test_trace_retry.cpp index be4174751..92c70c4b9 100644 --- a/test/test_trace_retry/test_trace_retry.cpp +++ b/test/test_trace_retry/test_trace_retry.cpp @@ -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 { diff --git a/test/test_usb_logging_client_activity.py b/test/test_usb_logging_client_activity.py new file mode 100644 index 000000000..e553908d7 --- /dev/null +++ b/test/test_usb_logging_client_activity.py @@ -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 +#include +#include +#include +#include +#include + +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() diff --git a/test/test_usb_logging_watchdog.py b/test/test_usb_logging_watchdog.py new file mode 100644 index 000000000..a91f0a9e7 --- /dev/null +++ b/test/test_usb_logging_watchdog.py @@ -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 +#include +#include +#include +#include +#include + +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() diff --git a/test/test_usb_logging_watchdog_roles.py b/test/test_usb_logging_watchdog_roles.py new file mode 100644 index 000000000..8d8a5451e --- /dev/null +++ b/test/test_usb_logging_watchdog_roles.py @@ -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 +#include +#include +#include +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() diff --git a/test/test_usb_logging_watchdog_runtime.py b/test/test_usb_logging_watchdog_runtime.py new file mode 100644 index 000000000..4a3cdacc7 --- /dev/null +++ b/test/test_usb_logging_watchdog_runtime.py @@ -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 +#include +#include +#include +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() diff --git a/test/test_usb_watchdog_event.py b/test/test_usb_watchdog_event.py new file mode 100644 index 000000000..e77807dfa --- /dev/null +++ b/test/test_usb_watchdog_event.py @@ -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 +#include +#include +#include +#include + +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 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() diff --git a/tools/management/report.py b/tools/management/report.py index f1bea8545..8b5cbb324 100644 --- a/tools/management/report.py +++ b/tools/management/report.py @@ -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("> 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("> 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