diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index d2e417e5..7742a79d 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -47,6 +47,8 @@ over the normal binary USB, BLE, or TCP connection: | Command | Availability | |---|---| +| [`board`](cli_commands.md#show-the-hardware-name) | Every Companion text terminal and command `0x42` | +| [`memory`](#memory) | ESP32 Full Companion text terminal and command `0x42` | | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Companion with a supported radio | | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Companion on a board with controllable LoRa FEM LNA | | [`get/set wifi.powersave`](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server) | ESP32 WiFi Companion; active transport constraints still apply | diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 6aa130ae..2ff7bb07 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -372,9 +372,11 @@ The terminal supports Companion chat commands, including `channels`, `channel `, remote administration with `login ` and `cmd `, and routed `trace [recipient-name-or-prefix]`, plus local `ota`, `tempradio`, and -`normalradio` controls. ESP32 builds also provide local -WiFi credential, status, WebConfig, CLI-tab, and power-save controls. Every -Full Companion provides persistent `get/set usb.logging` and starts with +`normalradio` controls. `board` reports the exact hardware target. ESP32 Full +Companion also provides `memory`, which reports heap, internal allocation, +PSRAM, and offline-queue usage; it is omitted from non-ESP32 help. ESP32 builds +also provide local WiFi credential, status, WebConfig, and power-save controls. +Every Full Companion provides persistent `get/set usb.logging` and starts with logging off on a fresh installation. ### ESP32 single USB serial port diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 725698f2..8067db68 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -9,7 +9,12 @@ #include "helpers/radiolib/RxBoostedGainDefaults.h" #include "helpers/radiolib/CadTiming.h" -#if defined(ESP32_PLATFORM) && defined(BOARD_HAS_PSRAM) +#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS +#include +#endif + +#if defined(ESP32_PLATFORM) \ + && (defined(BOARD_HAS_PSRAM) || COMPANION_FEATURE_MEMORY_DIAGNOSTICS) #include #endif @@ -2043,6 +2048,31 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (!command || !reply || reply_size == 0) return false; while (*command == ' ') command++; + if (strcmp(command, "board") == 0) { + const char* hardware_name = board.getManufacturerName(); + snprintf(reply, reply_size, "%s", + hardware_name != NULL ? hardware_name : "Unknown hardware"); + return true; + } + +#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS + if (strcmp(command, "memory") == 0) { + const mesh::CompanionMemoryDiagnostics diagnostics = { + (uint32_t)ESP.getFreeHeap(), + (uint32_t)ESP.getMinFreeHeap(), + (uint32_t)ESP.getMaxAllocHeap(), + (uint32_t)heap_caps_get_free_size(MALLOC_CAP_INTERNAL), + (uint32_t)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL), + (uint32_t)ESP.getFreePsram(), + (uint32_t)ESP.getPsramSize(), + offline_queue_len, + getOfflineQueueCapacity() + }; + mesh::formatCompanionMemoryDiagnostics(reply, reply_size, diagnostics); + return true; + } +#endif + if (handleCadCommand(command, reply, reply_size)) return true; if (strcmp(command, "get radio.rxgain") == 0) { @@ -5979,6 +6009,10 @@ void MyMesh::handleTerminalCommand(char* command) { FIRMWARE_VERSION, (unsigned)FIRMWARE_VER_CODE, FIRMWARE_BUILD_DATE); } else if (strcmp(command, "help") == 0) { terminalOutput().print("Commands:\r\n"); + terminalOutput().print(" board\r\n"); +#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS + terminalOutput().print(" memory\r\n"); +#endif terminalOutput().print(" get display.rotation\r\n"); terminalOutput().print(" set display.rotation <0|90|180|270>\r\n"); terminalOutput().print(" set {name|lat|lon|freq|tx|af} {value}\r\n"); @@ -6220,11 +6254,6 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, return true; } - if (strcmp(command, "board") == 0) { - snprintf(reply, reply_capacity, "%s", board.getManufacturerName()); - return true; - } - if (strcmp(command, "ver") == 0) { snprintf(reply, reply_capacity, "%s (Build: %s)", FIRMWARE_VERSION, FIRMWARE_BUILD_DATE); diff --git a/src/helpers/CompanionTerminalDiagnostics.h b/src/helpers/CompanionTerminalDiagnostics.h new file mode 100644 index 00000000..b57afacd --- /dev/null +++ b/src/helpers/CompanionTerminalDiagnostics.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +namespace mesh { + +struct CompanionMemoryDiagnostics { + uint32_t heap_free; + uint32_t heap_min; + uint32_t heap_max_alloc; + uint32_t internal_free; + uint32_t internal_max_alloc; + uint32_t psram_free; + uint32_t psram_total; + int queue_used; + int queue_capacity; +}; + +inline bool formatCompanionMemoryDiagnostics( + char* output, size_t output_size, + const CompanionMemoryDiagnostics& diagnostics) { + if (output == nullptr || output_size == 0) return false; + const int written = snprintf( + output, output_size, + "Heap free=%lu min=%lu max=%lu int=%lu/%lu PSRAM=%lu/%lu queue=%d/%d", + (unsigned long)diagnostics.heap_free, + (unsigned long)diagnostics.heap_min, + (unsigned long)diagnostics.heap_max_alloc, + (unsigned long)diagnostics.internal_free, + (unsigned long)diagnostics.internal_max_alloc, + (unsigned long)diagnostics.psram_free, + (unsigned long)diagnostics.psram_total, + diagnostics.queue_used, diagnostics.queue_capacity); + return written >= 0 && static_cast(written) < output_size; +} + +} // namespace mesh diff --git a/test/README.md b/test/README.md index 8f9e1a41..3409389b 100644 --- a/test/README.md +++ b/test/README.md @@ -14,6 +14,7 @@ pio test -e native # all suites except KISS modem pio test -e native_kiss_modem # KISS modem suite pio test -e native -f test_webconfig_keys # a single suite python3 test/test_indicator_display_profile.py # Indicator RAM/scale contract +python3 test/test_companion_terminal_profile.py # Companion CLI capability gates ``` A green `[PASSED]` per suite means GoogleTest returned 0 (all assertions @@ -57,6 +58,7 @@ does not reflect the GoogleTest count -- run the built binary directly | `test_remote_cli_reply_cache` | `src/helpers/RemoteCliReplyCache.h`, `src/helpers/RemoteCliRequest.h`, `src/helpers/RemoteCliTimeout.h` | authenticated logical-request matching; bounded recent-reply history; backward-compatible retry identity; 300% response timeout; empty-response completion; on-air truncation and clearing | | `test_companion_frame_queue` | `src/helpers/CompanionFrameQueue.h`, `src/helpers/CompanionHardwareCommandCompat.h` | response/required/best-effort classification; reserved capacity; stable priority; safe eviction; message-waiting coalescing; command `0x42` framed-CLI disambiguation and deprecated hardware-alias mapping | | `test_companion_status_response` | `src/helpers/CompanionStatusResponse.h` | request-tag correlation and minimum status-response length, including rejection of the three-entry ACL payload that previously masqueraded as status | +| `test_companion_terminal_diagnostics` | `src/helpers/CompanionTerminalDiagnostics.h` | bounded Full Companion ESP32 heap/PSRAM/offline-queue diagnostic formatting, including truthful no-PSRAM output | | `test_serial_mode_switch` | `src/helpers/ArduinoSerialInterface.cpp`, `src/helpers/MultiSerialInterface.h` | terminal/seeder control-sequence recognition, single-TTY logging transitions, and USB/TCP ownership; queued/atomic USB output under backpressure and short writes; partial-frame busy state; requester-affine replies, locked contact streams, and Bluetooth-only pairing routing | | `test_ble_tx_stall_watchdog` | `src/helpers/BleTxStallWatchdog.h` | exact BLE fragment progress; blocked-reply timeout; rollover-safe elapsed time; disconnect recovery retry and completion | | `test_ble_mota_control` | `src/helpers/BleMotaStream.h`, `CompanionMotaControl.h` | encrypted mOTA channel ring buffering, overflow fail-closed behavior, request gating, and strict rejection of injected or USB-ownership control commands | diff --git a/test/test_companion_terminal_diagnostics/test_companion_terminal_diagnostics.cpp b/test/test_companion_terminal_diagnostics/test_companion_terminal_diagnostics.cpp new file mode 100644 index 00000000..543a0406 --- /dev/null +++ b/test/test_companion_terminal_diagnostics/test_companion_terminal_diagnostics.cpp @@ -0,0 +1,48 @@ +#include + +#include + +#include + +TEST(CompanionTerminalDiagnostics, ReportsEveryEsp32MemoryField) { + const mesh::CompanionMemoryDiagnostics diagnostics = { + 66244, 2516, 63428, 61004, 42812, 6442475, 8386055, 3, 512 + }; + char output[160] = {}; + + ASSERT_TRUE(mesh::formatCompanionMemoryDiagnostics( + output, sizeof(output), diagnostics)); + EXPECT_STREQ( + "Heap free=66244 min=2516 max=63428 int=61004/42812 " + "PSRAM=6442475/8386055 queue=3/512", + output); +} + +TEST(CompanionTerminalDiagnostics, TruthfullyReportsNoPsram) { + const mesh::CompanionMemoryDiagnostics diagnostics = { + 104857, 98234, 90112, 104857, 90112, 0, 0, 0, 16 + }; + char output[160] = {}; + + ASSERT_TRUE(mesh::formatCompanionMemoryDiagnostics( + output, sizeof(output), diagnostics)); + EXPECT_NE(nullptr, strstr(output, "PSRAM=0/0")); + EXPECT_NE(nullptr, strstr(output, "queue=0/16")); +} + +TEST(CompanionTerminalDiagnostics, DetectsAReplyBufferThatIsTooSmall) { + const mesh::CompanionMemoryDiagnostics diagnostics = { + UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX, UINT32_MAX, + UINT32_MAX, UINT32_MAX, 512, 512 + }; + char output[24] = {}; + + EXPECT_FALSE(mesh::formatCompanionMemoryDiagnostics( + output, sizeof(output), diagnostics)); + EXPECT_EQ('\0', output[sizeof(output) - 1]); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/test/test_companion_terminal_profile.py b/test/test_companion_terminal_profile.py new file mode 100644 index 00000000..77da34af --- /dev/null +++ b/test/test_companion_terminal_profile.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import re + + +root = Path(__file__).resolve().parents[1] +source = (root / "examples/companion_radio/MyMesh.cpp").read_text() +features = (root / "examples/companion_radio/CompanionFeatures.h").read_text() + +local_start = source.index("bool MyMesh::handleLocalControlCommand(") +local_end = source.index("\n#if COMPANION_FEATURE_TEMP_RADIO\nvoid MyMesh::serviceTempRadio()", local_start) +local = source[local_start:local_end] + +assert local.count('strcmp(command, "board") == 0') == 1 +assert 'board.getManufacturerName()' in local +assert re.search( + r"#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS\s+" + r'if \(strcmp\(command, "memory"\) == 0\).*?' + r"formatCompanionMemoryDiagnostics.*?#endif", + local, + re.DOTALL, +) +assert "offline_queue_len" in local +assert "getOfflineQueueCapacity()" in local + +terminal_start = source.index("void MyMesh::handleTerminalCommand(") +terminal_end = source.index("\nvoid MyMesh::enterCLIRescue()", terminal_start) +terminal = source[terminal_start:terminal_end] +assert 'terminalOutput().print(" board\\r\\n")' in terminal +assert re.search( + r"#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS\s+" + r'terminalOutput\(\)\.print\(" memory\\r\\n"\);\s+#endif', + terminal, +) + +# This compile-time contract prevents either the implementation or help entry +# from leaking onto a non-ESP32 build merely because a recipe set the flag. +assert re.search( + r"#if COMPANION_FEATURE_MEMORY_DIAGNOSTICS && !defined\(ESP32_PLATFORM\)\s+" + r'#error "COMPANION_FEATURE_MEMORY_DIAGNOSTICS requires ESP32"', + features, +) + +print("test_companion_terminal_profile: PASS")