From 55f67f9062a545b2ac31ddac3868340a58c1bf1d Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 17 Oct 2026 19:35:05 -0700 Subject: [PATCH] Fit Mini Repeater with approved optional service cuts --- .github/workflows/run-unit-tests.yml | 25 ++ build_legacy.sh | 10 + docs/carrier_wave.md | 4 + docs/cli_commands.md | 9 +- docs/management_reports.md | 4 + docs/research/firmware_memory_budget.md | 50 +++ examples/simple_repeater/MyMesh.cpp | 32 +- examples/simple_repeater/MyMesh.h | 12 +- scripts/stm32_mini_size.py | 15 + src/helpers/CarrierTools.h | 11 + src/helpers/CommonCLI.cpp | 10 + src/helpers/CommonCLI_Management.cpp | 54 ++- src/helpers/ManagementFeatureConfig.h | 11 + src/helpers/ManagementReporter.cpp | 3 + src/helpers/PacketFileLoggingConfig.h | 11 + src/helpers/RadioProfileCLI.cpp | 5 + src/helpers/radiolib/CustomSTM32WLxWrapper.h | 14 + src/helpers/radiolib/RadioLibWrappers.cpp | 38 +- src/helpers/radiolib/RadioLibWrappers.h | 29 ++ test/test_carrier_wave.py | 9 +- test/test_ed25519_compact_field.py | 3 +- test/test_ed25519_compact_scalar.py | 3 +- test/test_esp32_tinyusb_cooperative_output.py | 85 +++- test/test_management_feature_gate.py | 197 +++++++++ test/test_mini_repeater_logging.py | 416 ++++++++++++++++++ test/test_morse_rx.py | 4 +- test/test_radio_profile_cli.py | 9 +- test/test_stm32_companion_profile.py | 10 +- test/test_stm32_mini_repeater_profile.py | 188 ++++++++ variants/wio-e5-mini/platformio.ini | 22 +- 30 files changed, 1259 insertions(+), 34 deletions(-) create mode 100644 scripts/stm32_mini_size.py create mode 100644 src/helpers/CarrierTools.h create mode 100644 src/helpers/ManagementFeatureConfig.h create mode 100644 src/helpers/PacketFileLoggingConfig.h create mode 100644 test/test_management_feature_gate.py create mode 100644 test/test_mini_repeater_logging.py create mode 100644 test/test_stm32_mini_repeater_profile.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 74eb4b399..2ad3b38e8 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -136,6 +136,31 @@ jobs: --radio-preset usa-cascade-fixed --profile cascade --require-ota done + stm32-mini-repeater-smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Build Environment + uses: ./.github/actions/setup-build-environment + - name: Verify constrained Mini Repeater feature boundaries + run: | + python3 -B test/test_stm32_mini_repeater_profile.py -v + python3 -B test/test_mini_repeater_logging.py -v + python3 -B test/test_management_feature_gate.py -v + python3 -B test/test_carrier_wave.py -v + python3 -B test/test_radio_profile_cli.py -v + python3 -B test/test_ed25519_compact_scalar.py -v + python3 -B test/test_ed25519_compact_field.py -v + python3 -B test/test_ed25519_compact.py -v + - name: Qualify cable-only Mini Repeater with unchanged flash and RAM gates + env: + DISABLE_DEBUG: '0' + run: | + set -euo pipefail + bash build.sh build-firmware wio-e5-mini_repeater \ + --firmware-version v1.18.0.1-halo-keymind-cascade-dev \ + --radio-preset usa-cascade-fixed --profile cascade --allow-no-ota + bootloader-version: runs-on: ubuntu-latest steps: diff --git a/build_legacy.sh b/build_legacy.sh index 4e4242896..f52705e57 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -3498,6 +3498,16 @@ declare_build_capability_contract() { declare_full_logging_application_contract "$env_name" record_build_capability "profile.${BUILD_PROFILE_FOR_TARGET}" + if [ "$env_name" = wio-e5-mini_repeater ]; then + # This exact fixed-capacity recipe intentionally omits these three + # optional services. Keep the packaged contract explicit about its cuts. + record_build_reduction \ + "radio.carrier_tools omitted to preserve the Mini Repeater app boundary" + record_build_reduction \ + "management.reporting and data.tx omitted to preserve the Mini Repeater app boundary" + record_build_reduction \ + "logging.file omitted; live USB/MQTT packet logging retained" + fi pio_env_name=$(get_pio_build_env "$env_name") if is_qspi_hardware_qualification_target "$env_name"; then record_build_capability "hardware.qualification.pending" diff --git a/docs/carrier_wave.md b/docs/carrier_wave.md index e97318d52..fb1bd7f15 100644 --- a/docs/carrier_wave.md +++ b/docs/carrier_wave.md @@ -4,6 +4,10 @@ # Carrier-wave RF checks +The constrained `wio-e5-mini_repeater` omits Morse and CW tools to fit its +existing application region. Its packet radio and radio2 are unchanged. +Other boards keep their existing carrier-tool support. + ## TX-only Morse messages Morse TX mode keys an unmodulated carrier into dots and dashes. It does not send diff --git a/docs/cli_commands.md b/docs/cli_commands.md index fa46306b8..ba6613828 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -23,8 +23,10 @@ See [carrier-wave RF checks](carrier_wave.md) for `cw on [seconds]`, For keyed Morse TX, use `morse send TEST DE N0CALL` and `morse stop`. Defaults are **902.100 MHz and 25 WPM**; `set morse.freq ` and `set morse.wpm <1–60>` save separate frequency/speed settings. -Small-flash STM32 production builds retain Morse TX but do not include Morse +Small-flash STM32 Companion builds retain Morse TX but do not include Morse RX by default; listen/read/stats/clear return an unsupported-RX error there. +The constrained `wio-e5-mini_repeater` omits Morse TX/RX and CW tools entirely; +normal packet TX/RX and radio2 remain available. `get morse` shows status. See [TX-only Morse messages](carrier_wave.md#tx-only-morse-messages) for supported characters, limits, overlap checks and the temporary pause in packet reception. Nothing transmits automatically. @@ -1423,6 +1425,11 @@ only `get mqtt.enabled`. WiFi modem power saving is a separate setting. **Search terms:** save logs, record received packets, RX logging, stored packet log. +The constrained `wio-e5-mini_repeater` omits node-storage packet logging: +`log start`, `log stop`, `log erase` and `log` are unavailable. Live +USB/MQTT packet logging, `set usb.logging on|off` and statistics remain. +Existing saved log files are not automatically deleted. + **Usage:** `log start` --- diff --git a/docs/management_reports.md b/docs/management_reports.md index 2be910d19..fc8c5c236 100644 --- a/docs/management_reports.md +++ b/docs/management_reports.md @@ -5,6 +5,10 @@ # Management reports (MGR1/MGR2) Available on repeater (including observer), room-server and sensor firmware. +Exception: the constrained `wio-e5-mini_repeater` omits management reporting +and shared `data.tx` routing to fit its existing application region. Normal +authenticated administration, statistics, live USB/MQTT logging and the +host-service bridge remain available. Companions/terminal-chat nodes and KISS modems do not originate these reports. Off by default. A management password and explicit enable are both required. No radio settings, existing preferences layout, or OTA authorization policy is diff --git a/docs/research/firmware_memory_budget.md b/docs/research/firmware_memory_budget.md index cd5429d97..db3356d4f 100644 --- a/docs/research/firmware_memory_budget.md +++ b/docs/research/firmware_memory_budget.md @@ -742,3 +742,53 @@ this experiment. No hardware was flashed, no failing image was published, and existing build outputs were not overwritten. Production compiler pins, features and capacity checks remain unchanged; the private source/tests, configs, verbose logs and linker maps were retained for further work. + +### Mini Repeater production recipe after file-logging approval + +After approval to also omit persistent packet-file logging, the three gates +were adopted only for `wio-e5-mini_repeater`: +`MESH_ENABLE_CARRIER_TOOLS=0`, +`MESH_ENABLE_MANAGEMENT_REPORTING=0` and +`MESH_ENABLE_PACKET_FILE_LOGGING=0`. All three default to 1 for other +boards/roles. No compact numeric GET handler was adopted. + +The production recipe retains the existing GCC 14.2 package pin, adds the +equivalent compact SHA-512/scalar/field implementations and unused Arduino +timer-wrapper trims, and applies the five measured compiler flags to both +compilation and the final LTO driver. The target-specific link hook is inert +for other environments. Existing flash and runtime-RAM checks remain active. + +Canonical `build.sh build-firmware` links at release-version length +(`v1.18.0.1-halo-keymind-cascade-dev`, Cascade, USA preset, +`DISABLE_DEBUG=0`) pass: + +| Canonical target | Linked flash extent | App limit | Flash headroom | Runtime RAM available / required | +| --- | ---: | ---: | ---: | ---: | +| Wio-E5 Mini Repeater | 245,376 | 245,760 | 384 | 44,560 / 32,288 | +| Wio-E5 Mini USB Companion regression control | 229,096 | 229,376 | 280 | 24,256 / 17,408 | + +These are conservative complete flash extents, not PlatformIO's selected +section sums. The Repeater still reserves its original 16 KiB filesystem, +50 neighbours, radio2, authenticated administration, host-service bridge and +runtime-controlled live USB packet logging for an external MQTT bridge. +The Companion keeps its 32 KiB filesystem and existing features. GCC 15 is +not required and no toolchain upgrade was adopted. + +The omitted file feature is only node-local `/packet_log` capture and +`log start|stop|erase` / local `log` dumping. Live raw RX/TX framing, USB +logging controls, statistics, watchdogs and bridge callbacks remain. Old +saved log files are left untouched rather than automatically erased. +Management-disabled routing fails closed and retains shared initialization +needed by independently enabled Fleet/beacon features. + +Focused coverage now runs in CI alongside an exact cable-only Mini Repeater +build at release-version length. It covers the feature gates, default-on +behavior, raw logging and backpressure under all eight flag combinations, +shared cooperative serial output, crypto equivalence, CLI permissions and +Morse/radio recovery. Production-source and host-test review found no blocker. + +Both flash margins are narrow: future changes must re-link, not assume these +images fit. These results qualify linked capacity and host behavior, not +physical USB/RF/MQTT integration, board boot/sleep or a sustained-load soak. +The Mini Repeater remains cable-only (`--allow-no-ota`); no hardware was +flashed, no release was published and existing outputs were not overwritten. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index fc841b6ac..ccee74038 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -942,6 +942,7 @@ mesh::Packet *MyMesh::createSelfAdvert() { return createAdvert(self_id, app_data, app_data_len); } +#if MESH_ENABLE_PACKET_FILE_LOGGING File MyMesh::openAppend(const char *fname) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) return _fs->open(fname, FILE_O_WRITE); @@ -951,6 +952,7 @@ File MyMesh::openAppend(const char *fname) { return _fs->open(fname, "a", true); #endif } +#endif static uint8_t max_loop_minimal[] = { 0, /* 1-byte */ 4, /* 2-byte */ 2, /* 3-byte */ 1 }; static uint8_t max_loop_moderate[] = { 0, /* 1-byte */ 2, /* 2-byte */ 1, /* 3-byte */ 1 }; @@ -1229,6 +1231,7 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { } #endif +#if MESH_ENABLE_PACKET_FILE_LOGGING if (_logging) { File f = openAppend(PACKET_LOG_FILE); if (f) { @@ -1246,6 +1249,7 @@ void MyMesh::logRx(mesh::Packet *pkt, int len, float score) { f.close(); } } +#endif } void MyMesh::logTx(mesh::Packet *pkt, int len) { @@ -1285,6 +1289,7 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { } #endif +#if MESH_ENABLE_PACKET_FILE_LOGGING if (_logging) { File f = openAppend(PACKET_LOG_FILE); if (f) { @@ -1301,6 +1306,7 @@ void MyMesh::logTx(mesh::Packet *pkt, int len) { f.close(); } } +#endif } void MyMesh::logTxFail(mesh::Packet *pkt, int len) { @@ -1316,6 +1322,7 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { } #endif +#if MESH_ENABLE_PACKET_FILE_LOGGING if (_logging) { File f = openAppend(PACKET_LOG_FILE); if (f) { @@ -1325,6 +1332,7 @@ void MyMesh::logTxFail(mesh::Packet *pkt, int len) { f.close(); } } +#endif } int MyMesh::calcRxDelay(float score, uint32_t air_time) const { @@ -1692,6 +1700,7 @@ void MyMesh::onDirectRetryEvent(const char* event, const mesh::Packet* packet, u (uint32_t)log_cr, (uint32_t)log_preamble_len); #endif +#if MESH_ENABLE_PACKET_FILE_LOGGING if (_logging) { File f = openAppend(PACKET_LOG_FILE); if (f) { @@ -1708,6 +1717,7 @@ void MyMesh::onDirectRetryEvent(const char* event, const mesh::Packet* packet, u f.close(); } } +#endif } void MyMesh::onDirectRetryFailed(const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) { @@ -2279,6 +2289,7 @@ void MyMesh::onFloodRetryEvent(const char* event, const mesh::Packet* packet, ui if (packet == NULL) { MESH_DEBUG_PRINTLN("flood retry %s (retry=%u, elapsed_ms=%lu, packet=released)", event, (unsigned int)retry_attempt, (unsigned long)delay_millis); +#if MESH_ENABLE_PACKET_FILE_LOGGING if (_logging) { File f = openAppend(PACKET_LOG_FILE); if (f) { @@ -2288,6 +2299,7 @@ void MyMesh::onFloodRetryEvent(const char* event, const mesh::Packet* packet, ui f.close(); } } +#endif return; } @@ -2327,6 +2339,7 @@ void MyMesh::onFloodRetryEvent(const char* event, const mesh::Packet* packet, ui (uint32_t)log_cr, (uint32_t)log_preamble_len); +#if MESH_ENABLE_PACKET_FILE_LOGGING if (_logging) { File f = openAppend(PACKET_LOG_FILE); if (f) { @@ -2347,6 +2360,7 @@ void MyMesh::onFloodRetryEvent(const char* event, const mesh::Packet* packet, ui f.close(); } } +#endif } @@ -5370,6 +5384,7 @@ void MyMesh::updateFloodAdvertTimer() { } } +#if MESH_ENABLE_PACKET_FILE_LOGGING void MyMesh::dumpLogFile() { #if defined(WITH_WEBCONFIG) || defined(ETHERNET_ENABLED) if (_command_output) { @@ -5401,6 +5416,9 @@ void MyMesh::dumpLogFile() { } #endif } +#else +void MyMesh::dumpLogFile() { } +#endif void MyMesh::printAclSerial() { #if MESH_USB_CONSOLE_COOPERATIVE @@ -5428,16 +5446,22 @@ void MyMesh::printAclSerial() { #if MESH_USB_CONSOLE_COOPERATIVE bool MyMesh::hasPendingSerialOutput() const { - return serial_log_active || serial_log_eof_pending || serial_recent_next >= 0 + return +#if MESH_ENABLE_PACKET_FILE_LOGGING + serial_log_active || serial_log_eof_pending || +#endif + serial_recent_next >= 0 || serial_acl_next >= 0; } void MyMesh::cancelPendingSerialOutput() { +#if MESH_ENABLE_PACKET_FILE_LOGGING if (serial_log_active) serial_log_dump.close(); serial_log_active = false; serial_log_eof_pending = false; serial_log_skip_line = false; serial_log_remaining = 0; +#endif serial_log_pending_size = 0; serial_acl_next = -1; serial_acl_count = 0; @@ -5553,6 +5577,7 @@ void MyMesh::servicePendingSerialOutput() { } return; } +#if MESH_ENABLE_PACKET_FILE_LOGGING if (!serial_log_active) { // CommonCLI's synchronous EOF is suppressed until the queued dump ends. static const char eof[] = " -> EOF\r\n"; @@ -5619,6 +5644,7 @@ void MyMesh::servicePendingSerialOutput() { serial_log_dump.close(); serial_log_active = false; } +#endif } #endif @@ -12304,7 +12330,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_USB_CONSOLE_COOPERATIVE +#if MESH_USB_CONSOLE_COOPERATIVE && MESH_ENABLE_PACKET_FILE_LOGGING if (sender_timestamp == 0 && serial_log_eof_pending && strcmp(reply, " EOF") == 0) reply[0] = 0; #endif @@ -12772,7 +12798,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_USB_CONSOLE_COOPERATIVE +#if MESH_USB_CONSOLE_COOPERATIVE && MESH_ENABLE_PACKET_FILE_LOGGING if (sender_timestamp == 0 && serial_log_eof_pending && strcmp(reply, " EOF") == 0) reply[0] = 0; #endif diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 89e44b5d6..08aac69f3 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #if defined(ENABLE_OTA) #include @@ -1142,10 +1143,19 @@ public: void updateAdvertTimer() override; void updateFloodAdvertTimer() override; - void setLoggingOn(bool enable) override { _logging = enable; } + void setLoggingOn(bool enable) override { +#if MESH_ENABLE_PACKET_FILE_LOGGING + _logging = enable; +#else + (void)enable; + _logging = false; +#endif + } void eraseLogFile() override { +#if MESH_ENABLE_PACKET_FILE_LOGGING _fs->remove(PACKET_LOG_FILE); +#endif } void dumpLogFile() override; diff --git a/scripts/stm32_mini_size.py b/scripts/stm32_mini_size.py new file mode 100644 index 000000000..82f2087cd --- /dev/null +++ b/scripts/stm32_mini_size.py @@ -0,0 +1,15 @@ +"""Retain the measured Mini Repeater size policy in the final LTO link.""" + +Import("env") + +# PlatformIO's build_flags apply these to compilation, but do not pass them +# to the LTO driver automatically. Keep this list matched by the profile test. +# No compiler/ABI, flash boundary or capacity check is overridden here. +if env["PIOENV"] == "wio-e5-mini_repeater": + env.Append(LINKFLAGS=[ + "-fno-caller-saves", + "-fno-tree-dominator-opts", + "-fno-reorder-blocks", + "-fno-reorder-functions", + "-fno-tree-switch-conversion", + ]) diff --git a/src/helpers/CarrierTools.h b/src/helpers/CarrierTools.h new file mode 100644 index 000000000..5c0cecc0f --- /dev/null +++ b/src/helpers/CarrierTools.h @@ -0,0 +1,11 @@ +#pragma once + +// Optional unmodulated transmit tools. Packet TX/RX and the independently +// controlled Morse receiver do not depend on these bench/CLI transmit modes. +#ifndef MESH_ENABLE_CARRIER_TOOLS +#define MESH_ENABLE_CARRIER_TOOLS 1 +#endif + +#if MESH_ENABLE_CARRIER_TOOLS != 0 && MESH_ENABLE_CARRIER_TOOLS != 1 +#error "MESH_ENABLE_CARRIER_TOOLS must be 0 or 1" +#endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 484ec8742..3d219d2c8 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1,4 +1,6 @@ #include +#include "CarrierTools.h" +#include "PacketFileLoggingConfig.h" #include "CommonCLI.h" #include "PrefsSaveReplyGuard.h" #include "UsbLoggingWatchdog.h" @@ -6,7 +8,9 @@ #include "HilStartupTrace.h" #include #include +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX #include +#endif #include #if defined(RAK_4631) #include @@ -780,7 +784,9 @@ static void formatSnrDbX4Short(char* dest, size_t dest_len, int16_t snr_x4) { void CommonCLI::loadPrefs(FILESYSTEM* fs) { mesh::hilStartupTrace("prefs_profiles_begin"); _radio_profiles.begin(fs, _callbacks->getProfileRadio(), _rtc, true); +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX mesh::loadMorseSettings(fs); +#endif mesh::hilStartupTrace("prefs_profiles_ready"); _prefs->primary_radio_preamble = _radio_profiles.primaryPreamble(); #if !defined(WITH_MQTT_BRIDGE) && (defined(ESP32_PLATFORM) || defined(RP2040_PLATFORM)) @@ -2973,9 +2979,11 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _callbacks->wirelessCommandSource(sender_timestamp))) return; // Infrastructure callers can already have reflected a three-byte CLI // request prefix into the same 160-byte reply. Reserve it for bulk pages. +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX if (mesh::handleMorseReplyCommand(_morse_reply, _callbacks->getProfileRadio(), command, reply, 160 - 3, millis())) return; if (mesh::handleMorseCommand(_callbacks->getProfileRadio(), command, reply, 160 - 3)) return; +#endif if (_radio_profiles.handle(command, reply, 160, sender_timestamp != 0)) return; #if defined(ENABLE_OTA) const auto* ota_context = mesh::ota::ota_context_if_active(); @@ -3583,6 +3591,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re _board->getShutdownReasonString(_board->getShutdownReason())); sprintf(reply + strlen(reply), "\r\nLast boot voltage: %u mV", _board->getBootVoltage()); #endif +#if MESH_ENABLE_PACKET_FILE_LOGGING } else if (memcmp(command, "log start", 9) == 0) { _callbacks->setLoggingOn(true); strcpy(reply, " logging on"); @@ -3595,6 +3604,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re } else if (sender_timestamp == 0 && memcmp(command, "log", 3) == 0) { _callbacks->dumpLogFile(); strcpy(reply, " EOF"); +#endif } else if (sender_timestamp == 0 && memcmp(command, "stats-packets", 13) == 0 && (command[13] == 0 || command[13] == ' ')) { _callbacks->formatPacketStatsReply(reply); } else if (sender_timestamp == 0 && memcmp(command, "stats-radio-diag", 16) == 0 && (command[16] == 0 || command[16] == ' ')) { diff --git a/src/helpers/CommonCLI_Management.cpp b/src/helpers/CommonCLI_Management.cpp index dd957255f..8099e1d07 100644 --- a/src/helpers/CommonCLI_Management.cpp +++ b/src/helpers/CommonCLI_Management.cpp @@ -1,10 +1,12 @@ +#include "ManagementFeatureConfig.h" #include "CommonCLI.h" +#include +#if MESH_ENABLE_MANAGEMENT_REPORTING #include "ManagementReporter.h" #include "FileRead.h" #include "FilePresence.h" #include "PersistentStoreFormat.h" #include "HilStartupTrace.h" -#include #include #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) #include "AtomicFileWriter.h" @@ -258,19 +260,23 @@ static bool resolveDataScope(DataRouteState& route, TransportKey& scope, return true; } } // namespace mesh +#endif void CommonCLI::beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs) { _management_mesh = &mesh; _management_fs = fs; #if MESH_ENABLE_TX_BEACONS beginBeacons(); #endif +#if MESH_ENABLE_MANAGEMENT_REPORTING _management_last_ms = millis(); _management_uptime_ms = _management_last_ms; +#endif #if MESH_ENABLE_FLEET_CONTROL if (_callbacks->supportsFleetControl() && (fs->exists("/fleet_channel") || fs->exists("/fleet_channel.bak"))) { _fleet_channel = new (std::nothrow) mesh::FleetChannel(fs, _callbacks, _region_map); } #endif +#if MESH_ENABLE_MANAGEMENT_REPORTING if (!_data_route) { mesh::hilStartupTrace("management_route_begin"); _data_route = new (std::nothrow) mesh::DataRouteState; @@ -289,6 +295,7 @@ void CommonCLI::beginManagement(mesh::Mesh& mesh, FILESYSTEM* fs) { mesh::hilStartupTrace("management_reporter_ready"); } mesh::hilStartupTrace("management_probe_ready"); +#endif } #if MESH_ENABLE_FLEET_CONTROL @@ -310,6 +317,7 @@ bool CommonCLI::handleFleetCommand(const char* command, char* reply) { } #endif +#if MESH_ENABLE_MANAGEMENT_REPORTING void CommonCLI::loopManagement() { const uint32_t now = millis(); _management_uptime_ms += uint32_t(now - _management_last_ms); _management_last_ms = now; @@ -456,3 +464,47 @@ bool CommonCLI::handleManagementCommand(char* command, char* reply) { if (!_management->command(command, reply, 160)) strcpy(reply, "ERR: unknown management setting"); return true; } +#else +void CommonCLI::loopManagement() { } + +bool CommonCLI::getDataTxPath(const uint8_t*& path, uint8_t& path_len) const { + path = nullptr; path_len = OUT_PATH_UNKNOWN; + return false; +} + +bool CommonCLI::resolveDataTxScope(TransportKey&, char* name, + size_t name_size, bool* ambiguous) const { + if (name && name_size) name[0] = 0; + if (ambiguous) *ambiguous = false; + return false; +} + +bool CommonCLI::adoptLegacyDataTxPath(const uint8_t*, uint8_t) { + return false; +} + +bool CommonCLI::setDataTxPath(const char*, char* reply, size_t reply_size) { + snprintf(reply, reply_size, "Err - data.tx unavailable"); + return false; +} + +bool CommonCLI::handleDataTxCommand(char* command, char* reply) { + if (strncmp(command, "get data.tx", 11) && strncmp(command, "set data.tx", 11)) return false; + if (command[11] && command[11] != ' ' && command[11] != '.') return false; + strcpy(reply, "Err - data.tx unavailable"); + return true; +} + +bool CommonCLI::handleManagementCommand(char* command, char* reply) { + if (handleDataTxCommand(command, reply)) return true; + if (strcmp(command, "get mgmt") && strncmp(command, "get mgmt.", 9) + && strncmp(command, "set mgmt.", 9)) return false; + if (!strncmp(command, "set mgmt.password ", 18)) { + // Retain recognized-password hygiene without pulling in report crypto. + volatile char* value = command + 18; + while (*value) *value++ = 0; + } + strcpy(reply, "ERR: management unavailable"); + return true; +} +#endif diff --git a/src/helpers/ManagementFeatureConfig.h b/src/helpers/ManagementFeatureConfig.h new file mode 100644 index 000000000..6fd03c5be --- /dev/null +++ b/src/helpers/ManagementFeatureConfig.h @@ -0,0 +1,11 @@ +#pragma once + +// Management reporting and its shared data.tx delivery route are included by +// default. Fixed-capacity builds can omit them without changing saved layouts. +#ifndef MESH_ENABLE_MANAGEMENT_REPORTING + #define MESH_ENABLE_MANAGEMENT_REPORTING 1 +#endif + +#if MESH_ENABLE_MANAGEMENT_REPORTING != 0 && MESH_ENABLE_MANAGEMENT_REPORTING != 1 + #error "MESH_ENABLE_MANAGEMENT_REPORTING must be 0 or 1" +#endif diff --git a/src/helpers/ManagementReporter.cpp b/src/helpers/ManagementReporter.cpp index c84d7fe5e..d2dbcae39 100644 --- a/src/helpers/ManagementReporter.cpp +++ b/src/helpers/ManagementReporter.cpp @@ -1,3 +1,5 @@ +#include "ManagementFeatureConfig.h" +#if MESH_ENABLE_MANAGEMENT_REPORTING #include "ManagementReporter.h" #include "CommonCLI.h" #include "FileRead.h" @@ -441,3 +443,4 @@ bool ManagementReporter::command(char* command, char* reply, size_t size) { snprintf(reply, size, "OK"); return true; } } +#endif diff --git a/src/helpers/PacketFileLoggingConfig.h b/src/helpers/PacketFileLoggingConfig.h new file mode 100644 index 000000000..f26f7a907 --- /dev/null +++ b/src/helpers/PacketFileLoggingConfig.h @@ -0,0 +1,11 @@ +#pragma once + +// Persistent packet-log files are independent of live USB and bridge logging. +// Normal boards retain file logging; constrained builds may omit it. +#ifndef MESH_ENABLE_PACKET_FILE_LOGGING + #define MESH_ENABLE_PACKET_FILE_LOGGING 1 +#endif + +#if MESH_ENABLE_PACKET_FILE_LOGGING != 0 && MESH_ENABLE_PACKET_FILE_LOGGING != 1 + #error "MESH_ENABLE_PACKET_FILE_LOGGING must be 0 or 1" +#endif diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 606c6b81a..3d3d53374 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -1,5 +1,8 @@ #include "RadioProfileCLI.h" +#include "CarrierTools.h" +#if MESH_ENABLE_CARRIER_TOOLS #include "CarrierWaveCLI.h" +#endif #include "RadioProfileCommandUtils.h" #include "FilePresence.h" #include "radiolib/RXPowerSaving.h" @@ -694,7 +697,9 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, #if defined(MESH_HIL_TX_FREQ_MHZ) if (handleHilTxFrequencyCommand(command, reply, capacity)) return true; #endif +#if MESH_ENABLE_CARRIER_TOOLS if (handleCarrierWaveCommand(radio_, command, reply, capacity)) return true; +#endif const char* text = command; enum { Get, Set, Delete } verb = Get; if (!strncmp(text, "get ", 4)) text += 4; diff --git a/src/helpers/radiolib/CustomSTM32WLxWrapper.h b/src/helpers/radiolib/CustomSTM32WLxWrapper.h index b82dc2137..91c048fe9 100644 --- a/src/helpers/radiolib/CustomSTM32WLxWrapper.h +++ b/src/helpers/radiolib/CustomSTM32WLxWrapper.h @@ -4,8 +4,12 @@ #include "CustomSTM32WLx.h" #include "RadioLibWrappers.h" #include "SX126xReset.h" +#if MESH_ENABLE_CARRIER_TOOLS #include "SX126xMorseRamp.h" +#endif +#if MESH_ENABLE_MORSE_RX #include "SX126xMorseReceiver.h" +#endif #include class CustomSTM32WLxWrapper : public RadioLibWrapper { @@ -17,7 +21,9 @@ protected: public: ~CustomSTM32WLxWrapper() { unregisterInterruptAction(); } CustomSTM32WLxWrapper(CustomSTM32WLx& radio, mesh::MainBoard& board) : RadioLibWrapper(radio, board) { } +#if MESH_ENABLE_CARRIER_TOOLS bool supportsMorseTx() const override { return true; } +#endif #if MESH_ENABLE_MESHTEXT_WIRE bool supportsMeshtext() const override { return true; } #endif @@ -54,6 +60,7 @@ protected: #if MESH_ENABLE_MORSE_RX float readMorseRssi() override { return static_cast(_radio)->getMorseRssi(); } #endif +#if MESH_ENABLE_CARRIER_TOOLS int16_t prepareMorseCarrier(float frequency_mhz) override { const int16_t status = RadioLibWrapper::prepareMorseCarrier(frequency_mhz); return status == RADIOLIB_ERR_NONE @@ -62,6 +69,8 @@ protected: int16_t leaveMorseCarrier() override { return leaveSX126xMorseCarrier(static_cast(_radio)); } +#endif +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX bool restoreCarrierWaveModem() override { #if MESH_ENABLE_MORSE_RX if (_morse_rx_fsk_mode) { @@ -70,9 +79,14 @@ protected: return restored; } #endif +#if MESH_ENABLE_CARRIER_TOOLS return restoreSX126xPacketRamp(static_cast(_radio)) == RADIOLIB_ERR_NONE && RadioLibWrapper::restoreCarrierWaveModem(); +#else + return RadioLibWrapper::restoreCarrierWaveModem(); +#endif } +#endif bool applyParams(float freq, float bw, uint8_t sf, uint8_t cr) override { return ((CustomSTM32WLx *)_radio)->setFrequency(freq) == RADIOLIB_ERR_NONE && ((CustomSTM32WLx *)_radio)->setSpreadingFactor(sf) == RADIOLIB_ERR_NONE diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 65b12cac7..678b1177c 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -251,6 +251,7 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) { #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return false; #endif +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX // A CW power-meter sweep may re-key the carrier. During Morse that would // turn an inter-symbol gap into RF and corrupt the text, so keep the whole // message exclusive (including cleanup retries). @@ -259,6 +260,7 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) { || _morse_rx_owned #endif ) && _cw_active) return false; +#if MESH_ENABLE_CARRIER_TOOLS if (_cw_active) { if (_cw_stopping) return false; #if defined(MESH_HIL_TX_FREQ_MHZ) @@ -280,6 +282,10 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) { _dbm_valid = true; return true; } +#else + if (_cw_active) return false; +#endif +#endif const uint8_t resume_rx = beginReconfigure(); if (resume_rx > 1) return false; @@ -1344,6 +1350,7 @@ void RadioLibWrapper::checkReceiveMode(uint32_t now) { recoverRadio(_rx_mode_failures >= 3); } +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX uint32_t RadioLibWrapper::carrierWaveRemainingMillis() const { if (!_cw_active || _cw_stopping) return 0; if (_morse_owned) return morseTxRemainingMillis(); @@ -1356,6 +1363,7 @@ uint32_t RadioLibWrapper::carrierWaveRemainingMillis() const { mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uint32_t duration_ms) { using Result = mesh::RadioParamApplyResult; +#if MESH_ENABLE_CARRIER_TOOLS // The shared emergency stop also covers chips exposing Morse without the // older profile-CW CLI capability. if (!duration_ms && (_morse_owned @@ -1418,8 +1426,16 @@ mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(uint8_t profile, uin } _cw_until = static_cast(millis()) + duration_ms; return Result::APPLIED; +#else + (void)profile; + // Keep the shared emergency stop available to an independently enabled RX. + return !duration_ms && _morse_rx_owned + ? (stopCarrierWave() ? Result::APPLIED : Result::FAILED) : Result::FAILED; +#endif } +#endif +#if MESH_ENABLE_CARRIER_TOOLS bool RadioLibWrapper::supportsMorseTx() const { return supportsCarrierWave(); } @@ -1532,6 +1548,7 @@ bool RadioLibWrapper::serviceMorseTx() { } return true; } +#endif #if MESH_ENABLE_MORSE_RX bool RadioLibWrapper::canAllocateMorseReceiver() const { @@ -1619,7 +1636,10 @@ mesh::MorseRxResult RadioLibWrapper::startMorseRx(float frequency_mhz, } _radio->clearPacketReceivedAction(); _cw_active = _morse_rx_owned = true; - _morse_owned = _cw_stopping = false; +#if MESH_ENABLE_CARRIER_TOOLS + _morse_owned = false; +#endif + _cw_stopping = false; _cw_profile = _active_profile; _morse_generation[0] = _profiles.generation[0]; _morse_generation[1] = _profiles.generation[1]; @@ -1685,10 +1705,8 @@ void RadioLibWrapper::clearMorseCapture() { } #endif +#if MESH_ENABLE_MORSE_RX bool RadioLibWrapper::serviceMorseRx() { -#if !MESH_ENABLE_MORSE_RX - return false; -#else if (!_morse_rx_owned || !_cw_active) return false; const uint32_t now = millis(); if (_cw_stopping) { @@ -1714,11 +1732,14 @@ bool RadioLibWrapper::serviceMorseRx() { } } return true; -#endif } +#endif +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX bool RadioLibWrapper::stopCarrierWave() { +#if MESH_ENABLE_CARRIER_TOOLS _morse.stop(); +#endif #if MESH_ENABLE_MORSE_RX if (_morse_rx_owned && _morse_rx) _morse_rx->stop(static_cast(millis())); #endif @@ -1770,7 +1791,9 @@ bool RadioLibWrapper::stopCarrierWave() { _cw_retry_at = static_cast(millis()) + 100; return false; } +#if MESH_ENABLE_CARRIER_TOOLS _morse_owned = false; +#endif #if MESH_ENABLE_MORSE_RX _morse_rx_owned = false; #endif @@ -1782,6 +1805,7 @@ bool RadioLibWrapper::serviceCarrierWave() { #if MESH_ENABLE_MORSE_RX if (serviceMorseRx()) return true; #endif +#if MESH_ENABLE_CARRIER_TOOLS if (serviceMorseTx()) return true; if (_cw_active) { #if defined(MESH_HIL_TX_FREQ_MHZ) @@ -1799,15 +1823,19 @@ bool RadioLibWrapper::serviceCarrierWave() { } return true; } +#endif return false; } +#endif void RadioLibWrapper::loop() { #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return; #endif syncProfileClock(_profiles.enabled()); +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX if (serviceCarrierWave()) return; +#endif if (_profiles.enabled()) { const unsigned long now = millis(); checkReceiveMode(static_cast(now)); diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 2afd143e3..7b9929f57 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -1,6 +1,7 @@ #pragma once #include "NoiseFloorEstimator.h" +#include "../CarrierTools.h" #include #include @@ -139,16 +140,26 @@ protected: #endif void cacheBeaconRestoreParams(); #endif +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX bool _cw_active = false; bool _cw_stopping = false; bool _cw_board_tx = false; uint8_t _cw_profile = 0, _cw_restore_profile = 0; uint32_t _cw_until = 0, _cw_retry_at = 0, _cw_generation = 0; +#else + static constexpr bool _cw_active = false; +#endif +#if MESH_ENABLE_CARRIER_TOOLS mesh::MorseTx _morse; bool _morse_owned = false; float _morse_frequency = mesh::MorseTx::DefaultFrequencyMHz; uint8_t _morse_wpm = mesh::MorseTx::DefaultWpm; +#else + static constexpr bool _morse_owned = false; +#endif +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX uint32_t _morse_generation[2] = {}; +#endif // Allocate the bounded capture only on explicit RX admission. Permanently // reserving 4096 characters would break small nRF52 Companion RAM budgets. #if MESH_ENABLE_MORSE_RX @@ -167,10 +178,16 @@ protected: bool _morse_rx_fsk_mode = false; #endif // End Morse RX state. +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX bool stopCarrierWave(); bool serviceCarrierWave(); +#endif +#if MESH_ENABLE_CARRIER_TOOLS bool serviceMorseTx(); +#endif +#if MESH_ENABLE_MORSE_RX bool serviceMorseRx(); +#endif mesh::RadioParamApplyResult tuneProfile(uint8_t profile); void serviceProfileScan(); @@ -297,10 +314,15 @@ protected: virtual bool checkBeaconOutputPower(float frequency_mhz, int8_t dbm) const; #endif virtual bool applyRxBoostedGainMode(bool) { return false; } +#if MESH_ENABLE_CARRIER_TOOLS || !defined(STM32_PLATFORM) virtual int16_t enterCarrierWave() { return _radio->transmitDirect(); } +#endif +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM) virtual bool validateMorseFrequency(float frequency_mhz) const { return isfinite(frequency_mhz) && frequency_mhz >= 150.0f && frequency_mhz <= 960.0f; } +#endif +#if MESH_ENABLE_CARRIER_TOOLS || !defined(STM32_PLATFORM) // Configure the oscillator/modem once, before the first mark. Subsequent // marks only re-enter direct TX; in particular SX127x must not rebuild FSK // between dots. The cached LoRa tuple is deliberately left untouched. @@ -312,6 +334,7 @@ protected: } virtual int16_t enterMorseCarrier() { return _radio->transmitDirect(); } virtual int16_t leaveMorseCarrier() { return _radio->standby(); } +#endif #if MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM) // Compact integrated-STM builds cannot receive Morse. Do not pin unused // native RX thunks and vtable slots in their already full application slot. @@ -330,9 +353,11 @@ protected: #endif // Native-CW radios keep their modem. Reapply cached power in case a meter // sweep failed halfway through; SX127x also rebuilds its LoRa configuration. +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM) virtual bool restoreCarrierWaveModem() { return !_dbm_valid || applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE; } +#endif // Dual-profile scanning already suspends RXPS. Supported radios can also // retain their oscillator across the intervening standby/reconfigure steps. // Called only at the safe scan-mode entry/exit boundary, not during a packet. @@ -383,10 +408,13 @@ protected: public: void begin() override; +#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX mesh::RadioParamApplyResult setCarrierWave(uint8_t profile, uint32_t duration_ms) override; bool isCarrierWaveActive() const override { return _cw_active; } uint8_t carrierWaveProfile() const override { return _cw_profile; } uint32_t carrierWaveRemainingMillis() const override; +#endif +#if MESH_ENABLE_CARRIER_TOOLS bool supportsMorseTx() const override; mesh::MorseTxResult startMorseTx(float frequency_mhz, uint8_t wpm, const char* text) override; mesh::MorseTxResult stopMorseTx() override; @@ -394,6 +422,7 @@ public: float morseTxFrequency() const override { return _morse_frequency; } uint8_t morseTxWpm() const override { return _morse_wpm; } uint32_t morseTxRemainingMillis() const override { return _morse.remainingMillis(millis()); } +#endif #if MESH_ENABLE_MORSE_RX bool supportsMorseRx() const override { return false; } mesh::MorseRxResult startMorseRx(float frequency_mhz, uint8_t wpm, uint32_t duration_ms) override; diff --git a/test/test_carrier_wave.py b/test/test_carrier_wave.py index d51965eb6..a6a07fd11 100644 --- a/test/test_carrier_wave.py +++ b/test/test_carrier_wave.py @@ -9,6 +9,7 @@ from test_radio_receive_contract import method ROOT = Path(__file__).resolve().parents[1] HARNESS = r''' #include +#include #include #include #include @@ -175,7 +176,7 @@ struct RadioLibWrapper : mesh::Radio { bool stopCarrierWave(); bool serviceCarrierWave(); bool serviceMorseTx(); - bool serviceMorseRx(); + @RX_SERVICE_DECLARATION@ bool supportsMorseTx() const override; mesh::MorseTxResult startMorseTx(float,uint8_t,const char*) override; mesh::MorseTxResult stopMorseTx() override; @@ -476,11 +477,15 @@ def production_harness(): if signature in ('mesh::MorseRxResult RadioLibWrapper::startMorseRx(', 'mesh::MorseRxResult RadioLibWrapper::stopMorseRx(', 'size_t RadioLibWrapper::readMorseCapture(', - 'void RadioLibWrapper::clearMorseCapture('): + 'void RadioLibWrapper::clearMorseCapture(', + 'bool RadioLibWrapper::serviceMorseRx('): return '#if MESH_ENABLE_MORSE_RX\n'+method(source,signature)+'\n#endif' return method(source,signature) code = HARNESS.replace('@METHODS@', '\n'.join(extract_transition(s) for s in signatures)) header = (ROOT/'src/helpers/radiolib/RadioLibWrappers.h').read_text() + service_start = header.rfind('#if MESH_ENABLE_MORSE_RX\n',0,header.index(' bool serviceMorseRx();')) + service_end = header.index('\n#endif',service_start) + len('\n#endif') + code = code.replace('@RX_SERVICE_DECLARATION@',header[service_start:service_end]) fields_start = header.rfind('#if MESH_ENABLE_MORSE_RX',0,header.index('mesh::MorseRx* _morse_rx')) fields_end = header.index(' // End Morse RX state.',fields_start) code = code.replace('@MORSE_RX_FIELDS@',header[fields_start:fields_end]) diff --git a/test/test_ed25519_compact_field.py b/test/test_ed25519_compact_field.py index 0f831146d..a017c6a32 100644 --- a/test/test_ed25519_compact_field.py +++ b/test/test_ed25519_compact_field.py @@ -222,7 +222,7 @@ int main(void) { str(harness), str(ED25519 / "fe.c"), "-o", str(executable)], check=True) subprocess.run([str(executable)], check=True) - def test_only_size_constrained_stm32_companions_opt_in(self): + def test_only_approved_size_constrained_stm32_profiles_opt_in(self): profiles = [] for ini in (ROOT / "variants").glob("*/platformio.ini"): section = "" @@ -236,6 +236,7 @@ int main(void) { ("tiny_relay", "[env:Tiny_Relay_companion_radio_usb]"), ("wio-e5-dev", "[env:wio-e5_companion_radio_usb]"), ("wio-e5-mini", "[env:wio-e5-mini_companion_radio_usb]"), + ("wio-e5-mini", "[env:wio-e5-mini_repeater]"), ]) diff --git a/test/test_ed25519_compact_scalar.py b/test/test_ed25519_compact_scalar.py index 222fb5bda..31b31b40f 100644 --- a/test/test_ed25519_compact_scalar.py +++ b/test/test_ed25519_compact_scalar.py @@ -128,7 +128,7 @@ int main(void) { str(harness), str(ED25519 / "sc.c"), "-o", str(executable)], check=True) subprocess.run([str(executable)], check=True) - def test_only_size_constrained_stm32_companions_enable_compact_scalars(self): + def test_only_approved_size_constrained_stm32_profiles_enable_compact_scalars(self): profiles = [] for ini in (ROOT / "variants").glob("*/platformio.ini"): section = "" @@ -142,6 +142,7 @@ int main(void) { ("tiny_relay", "[env:Tiny_Relay_companion_radio_usb]"), ("wio-e5-dev", "[env:wio-e5_companion_radio_usb]"), ("wio-e5-mini", "[env:wio-e5-mini_companion_radio_usb]"), + ("wio-e5-mini", "[env:wio-e5-mini_repeater]"), ]) diff --git a/test/test_esp32_tinyusb_cooperative_output.py b/test/test_esp32_tinyusb_cooperative_output.py index ec0e5adba..f0eccc0d8 100644 --- a/test/test_esp32_tinyusb_cooperative_output.py +++ b/test/test_esp32_tinyusb_cooperative_output.py @@ -8,6 +8,8 @@ import subprocess import tempfile import unittest +from cpp_source import body + ROOT = Path(__file__).resolve().parents[1] @@ -23,6 +25,7 @@ HARNESS = r''' #include #include #include +#include @USB_CONFIG@ #define MESH_ESP32_USB_CONSOLE_COOPERATIVE 1 #define MESH_USB_CONSOLE_COOPERATIVE 1 @@ -397,6 +400,23 @@ RECENT_TEST = r''' class CooperativeOutputTest(unittest.TestCase): + def role_methods(self, text, role): + dump_start = text.index("void MyMesh::dumpLogFile()") + guard = "#if MESH_ENABLE_PACKET_FILE_LOGGING\n" + if text[:dump_start].endswith(guard): + dump_start -= len(guard) + dump_end = text.index("\n#if MESH_USB_CONSOLE_COOPERATIVE\nbool MyMesh::hasPendingSerialOutput", dump_start) + methods = text[dump_start:dump_end] + "\n" + "\n".join(body(text, signature) for signature in ( + "bool MyMesh::hasPendingSerialOutput()", "void MyMesh::cancelPendingSerialOutput()", + "void MyMesh::servicePendingSerialOutput()")) + if role == "simple_repeater": + format_start = text.index("static void formatLocalSnrX4(") + format_end = text.index("\nvoid MyMesh::formatRecentRepeatersReply", format_start) + recent_start = text.index("void MyMesh::printRecentRepeatersSerial()") + recent_end = text.index("\nbool MyMesh::setRecentRepeater(", recent_start) + methods = text[format_start:format_end] + "\n" + text[recent_start:recent_end] + "\n" + methods + return methods + def test_usb_acl_commands_route_to_the_cooperative_pump(self): for role in ("simple_repeater", "simple_room_server"): with self.subTest(role=role): @@ -415,17 +435,7 @@ class CooperativeOutputTest(unittest.TestCase): for config in ("MESH_ESP32_TINYUSB_NONBLOCKING", "MESH_ESP32_HWCDC_SESSION_GUARD")): with self.subTest(role=role, transport=config), 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_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] - if role == "simple_repeater": - format_start = text.index("static void formatLocalSnrX4(") - format_end = text.index("\nvoid MyMesh::formatRecentRepeatersReply", format_start) - recent_start = text.index("void MyMesh::printRecentRepeatersSerial()") - recent_end = text.index("\nbool MyMesh::setRecentRepeater(", recent_start) - methods = text[format_start:format_end] + "\n" + text[recent_start:recent_end] + "\n" + methods + methods = self.role_methods(text, role) program = HARNESS.replace("@METHODS@", methods).replace( "@RECENT_TEST@", RECENT_TEST if role == "simple_repeater" else "" ).replace("@USB_CONFIG@", f"#define {config} 1") @@ -441,6 +451,59 @@ class CooperativeOutputTest(unittest.TestCase): ) self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + def test_file_off_control_preserves_shared_acl_and_recent_output(self): + compiler = shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "A host C++ compiler is required") + text = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text() + program = HARNESS[:HARNESS.index("int main() {")].replace( + "@METHODS@", self.role_methods(text, "simple_repeater")).replace( + "@USB_CONFIG@", "#define MESH_ESP32_TINYUSB_NONBLOCKING 1") + program += r''' +int main() { + setupFile("must not be read\n"); + MyMesh radio; + radio.dumpLogFile(); + require(!radio.hasPendingSerialOutput() && console.output.empty(), "file-off dump is active"); + require(fs.data->reads == 0, "file-off dump read persistent storage"); + std::string expected = "ACL:\r\n"; + for (int i = 0; i < 32; ++i) { + FakeAcl::Client client; + std::fill(std::begin(client.id.pub_key), std::end(client.id.pub_key), i); + radio.acl.clients.push_back(client); + char key[PUB_KEY_SIZE * 2 + 1], line[80]; + mesh::Utils::toHex(key, client.id.pub_key, PUB_KEY_SIZE); + snprintf(line, sizeof(line), "%02X %s\n", client.permissions, key); + expected += line; + } + radio.printAclSerial(); + require(radio.hasPendingSerialOutput(), "file-off ACL was disabled"); + console.capacity = 0; + radio.servicePendingSerialOutput(); + require(console.output.empty(), "file-off ACL ignored backpressure"); + console.short_limit = 3; + drain(radio, 73); + require(console.output == expected, "file-off ACL lost retained suffix"); + require(fs.data->reads == 0, "shared ACL output read persistent log"); + radio.printAclSerial(); + radio.servicePendingSerialOutput(); + radio.cancelPendingSerialOutput(); + require(!radio.hasPendingSerialOutput() && radio.serial_log_pending_size == 0, + "file-off cancellation retained ACL suffix"); + @RECENT_TEST@ + std::cout << "file-off shared output checks passed\n"; +} +''' + program = program.replace("@RECENT_TEST@", RECENT_TEST) + with tempfile.TemporaryDirectory(prefix="mini-file-off-output-") as directory: + executable = Path(directory) / "pump" + build = subprocess.run( + [compiler, "-std=c++17", "-O0", "-DMESH_ENABLE_PACKET_FILE_LOGGING=0", + "-I", str(ROOT / "src"), "-x", "c++", "-", "-o", str(executable)], + input=program, text=True, capture_output=True, timeout=60) + self.assertEqual(build.returncode, 0, build.stdout + build.stderr) + run = subprocess.run([str(executable)], text=True, capture_output=True, timeout=30) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + if __name__ == "__main__": unittest.main() diff --git a/test/test_management_feature_gate.py b/test/test_management_feature_gate.py new file mode 100644 index 000000000..4d3e9986f --- /dev/null +++ b/test/test_management_feature_gate.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Compile the complete disabled management implementation at its FS boundary.""" + +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + +HEADER = r''' +#pragma once +#include +#include +#include +#include +#include +#define OUT_PATH_UNKNOWN 255 +struct TransportKey { unsigned marker = 17; }; +struct Filesystem { + unsigned reads = 0; + bool exists(const char* path) { + // No management/data.tx probe, recovery or write is permitted when off. + assert(!strcmp(path, "/fleet_channel")); + ++reads; + return true; + } +}; +using FILESYSTEM = Filesystem; +struct Callbacks { bool supportsFleetControl() const { return true; } }; +namespace mesh { +struct Mesh {}; +struct RegionMap {}; +struct FleetChannel { + static inline unsigned live = 0; + FleetChannel(FILESYSTEM*, Callbacks*, RegionMap*) { ++live; } + ~FleetChannel() { --live; } + bool handleConfig(const char*, char* reply, size_t size) { + snprintf(reply, size, "fleet remains available"); return true; + } +}; +} +struct CommonCLI { + mesh::Mesh* _management_mesh = nullptr; + FILESYSTEM* _management_fs = nullptr; + mesh::RegionMap region; + mesh::RegionMap* _region_map = ®ion; + Callbacks callbacks; + Callbacks* _callbacks = &callbacks; + mesh::FleetChannel* _fleet_channel = nullptr; + unsigned beacon_starts = 0; + ~CommonCLI() { delete _fleet_channel; } + void beginBeacons() { ++beacon_starts; } + void beginManagement(mesh::Mesh&, FILESYSTEM*); + void loopManagement(); + bool handleFleetCommand(const char*, char*); + bool getDataTxPath(const uint8_t*&, uint8_t&) const; + bool resolveDataTxScope(TransportKey&, char*, size_t, bool*) const; + bool adoptLegacyDataTxPath(const uint8_t*, uint8_t); + bool setDataTxPath(const char*, char*, size_t); + bool handleDataTxCommand(char*, char*); + bool handleManagementCommand(char*, char*); +}; +''' + +HARNESS = r''' +#include "CommonCLI.h" +#include +#include +int main() { + Filesystem fs; + mesh::Mesh mesh; + { + CommonCLI cli; + cli.beginManagement(mesh, &fs); + assert(cli._management_mesh == &mesh && cli._management_fs == &fs); + assert(cli.beacon_starts == MESH_ENABLE_TX_BEACONS); + assert(mesh::FleetChannel::live == MESH_ENABLE_FLEET_CONTROL); + assert(fs.reads == MESH_ENABLE_FLEET_CONTROL); + cli.loopManagement(); + assert(fs.reads == MESH_ENABLE_FLEET_CONTROL); +#if MESH_ENABLE_FLEET_CONTROL + char fleet_reply[160] = {}; + assert(cli.handleFleetCommand("get fleet.stats", fleet_reply)); + assert(!strcmp(fleet_reply, "fleet remains available")); +#endif + const uint8_t byte = 42; + const uint8_t* path = &byte; + uint8_t length = 3; + assert(!cli.getDataTxPath(path, length)); + assert(path == nullptr && length == OUT_PATH_UNKNOWN); + assert(!cli.adoptLegacyDataTxPath(&byte, 1)); + TransportKey key; + char name[8] = "old"; + bool ambiguous = true; + assert(!cli.resolveDataTxScope(key, name, sizeof(name), &ambiguous)); + assert(!*name && !ambiguous && key.marker == 17); + assert(!cli.resolveDataTxScope(key, nullptr, 0, nullptr)); + for (const char* command : {"ver", "board", "stats-core", "stats-radio", + "stats-packets", "get usb.logging", "get radio2", + "get password", "host cpu-temp", "get data.txExtra"}) { + char input[160], reply[160] = "not handled"; + strcpy(input, command); + assert(!cli.handleManagementCommand(input, reply)); + assert(!strcmp(input, command) && !strcmp(reply, "not handled")); + } + for (const char* command : {"get mgmt", "get mgmt.password", "set mgmt.enabled on", + "get mgmt.path", "set mgmt.path direct", + "get data.tx", "get data.tx.path", "set data.tx region none"}) { + char input[160], reply[160] = {}; + strcpy(input, command); + assert(cli.handleManagementCommand(input, reply)); + assert(!strcmp(input, command)); + assert(!strcmp(reply, !strncmp(command + 4, "data.tx", 7) + ? "Err - data.tx unavailable" : "ERR: management unavailable")); + } + for (size_t length : {size_t(0), size_t(5), size_t(96), size_t(200)}) { + const std::string original = "set mgmt.password " + std::string(length, 's'); + std::vector guarded(original.size() + 9, '\x5A'); + char* command = guarded.data() + 4; + memcpy(command, original.c_str(), original.size() + 1); + char reply[160] = {}; + assert(cli.handleManagementCommand(command, reply)); + assert(!strcmp(reply, "ERR: management unavailable")); + assert(!memcmp(command, "set mgmt.password ", 18)); + for (size_t i = 18; i <= original.size(); ++i) assert(command[i] == 0); + for (size_t i = 0; i < 4; ++i) assert(guarded[i] == '\x5A'); + for (size_t i = original.size() + 5; i < guarded.size(); ++i) assert(guarded[i] == '\x5A'); + } + char guarded_reply[12]; memset(guarded_reply, '\x5A', sizeof(guarded_reply)); + assert(!cli.setDataTxPath("direct", guarded_reply + 2, 6)); + assert(guarded_reply[0] == '\x5A' && guarded_reply[1] == '\x5A'); + assert(guarded_reply[7] == 0 && guarded_reply[8] == '\x5A'); + assert(fs.reads == MESH_ENABLE_FLEET_CONTROL); + } + assert(mesh::FleetChannel::live == 0); + puts("PASS: disabled management fails closed, wipes passwords and retains Fleet/Beacon context"); +} +''' + + +class ManagementFeatureGateTest(unittest.TestCase): + def test_default_is_enabled_and_explicit_values_are_boolean(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++17 compiler is required") + header = ROOT / "src/helpers/ManagementFeatureConfig.h" + for platform in (None, "STM32_PLATFORM", "NRF52_PLATFORM", "ESP32_PLATFORM", "RP2040_PLATFORM"): + for value in (None, 0, 1, -1, 2): + with self.subTest(platform=platform, value=value): + command = [compiler, "-E", "-P", "-x", "c++", "-include", str(header)] + if platform: + command.append("-D" + platform + "=1") + if value is not None: + command.append("-DMESH_ENABLE_MANAGEMENT_REPORTING=" + str(value)) + command.append("-") + result = subprocess.run(command, input="MESH_ENABLE_MANAGEMENT_REPORTING\n", + capture_output=True, text=True, timeout=10) + if value in (-1, 2): + self.assertNotEqual(result.returncode, 0) + self.assertIn("must be 0 or 1", result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout.strip(), str(1 if value is None else value)) + + def test_whole_disabled_translation_units_have_no_crypto_or_storage_dependency(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="management-off-") as directory: + work = Path(directory) + (work / "CommonCLI.h").write_text(HEADER, encoding="ascii") + (work / "test.cpp").write_text(HARNESS, encoding="ascii") + for name in ("CommonCLI_Management.cpp", "ManagementReporter.cpp"): + shutil.copyfile(ROOT / "src/helpers" / name, work / name) + sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + for beacons, fleet in ((0, 0), (0, 1), (1, 0), (1, 1)): + with self.subTest(beacons=beacons, fleet=fleet): + executable = work / ("test-%u-%u" % (beacons, fleet)) + result = subprocess.run([ + compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", *sanitizers, + "-DMESH_ENABLE_MANAGEMENT_REPORTING=0", "-DMESH_ENABLE_TX_BEACONS=" + str(beacons), + "-DMESH_ENABLE_FLEET_CONTROL=" + str(fleet), "-I", str(work), + "-I", str(ROOT / "src/helpers"), str(work / "CommonCLI_Management.cpp"), + str(work / "ManagementReporter.cpp"), str(work / "test.cpp"), "-o", str(executable), + ], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(executable)], capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("PASS: disabled management fails closed", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mini_repeater_logging.py b/test/test_mini_repeater_logging.py new file mode 100644 index 000000000..ddf09f494 --- /dev/null +++ b/test/test_mini_repeater_logging.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""Keep constrained Mini Repeater builds compatible with the MQTT packet log. + +Compile the production repeater RX method, Dispatcher TX method, packet wire +codec and bounded serial writer under all eight feature-gate combinations. +Hardware transport/radio are stand-ins; this does not qualify physical USB/RF. +""" + +import configparser +import itertools +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +from cpp_source import body + + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +inline uint32_t micros() { return millis() * 1000U; } +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static_assert(MESH_PACKET_LOGGING == 1, "packet logging must remain compiled"); +static_assert(MESH_PACKET_LOGGING_COMPACT == 1, "retain compact serial protocol"); +static_assert(MESH_ENABLE_CARRIER_TOOLS == EXPECT_CARRIER, "exercise actual flag"); +static_assert(MESH_ENABLE_MANAGEMENT_REPORTING == EXPECT_MANAGEMENT, "exercise actual flag"); +static_assert(MESH_ENABLE_PACKET_FILE_LOGGING == EXPECT_FILE_LOGGING, "exercise actual flag"); + +struct Port : Stream { + std::string output; + bool stalled = false; + size_t chunk = 7, wedge_after = std::numeric_limits::max(); + int availableForWrite() override { + return stalled || output.size() >= wedge_after ? 0 : int(chunk); + } + size_t write(const uint8_t* data, size_t size) override { + if (!availableForWrite()) return 0; + size = std::min(size, chunk); + output.append(reinterpret_cast(data), size); + return size; + } +} log_port; + +namespace mesh { +bool logging_enabled = true; +bool isUsbLoggingEnabled() { return logging_enabled; } +Stream& usbLoggingPort() { return log_port; } + +@PACKET_METHODS@ + +enum class RadioParamApplyResult { APPLIED, BUSY }; +struct Profile { uint8_t cr = 5; }; +struct Profiles { + Profile value; + const Profile& params(uint8_t) const { return value; } +}; +struct Radio { + bool send_ok = true; + unsigned sends = 0; + std::vector raw; + Profiles settings; + RadioParamApplyResult prepareTransmitProfile(uint8_t, bool) { + return RadioParamApplyResult::APPLIED; + } + Profiles* profiles() { return &settings; } + uint32_t getEstAirtimeFor(int) { return 10; } + bool setCodingRate(uint8_t) { return true; } + bool startSendRaw(const uint8_t* bytes, int length) { + ++sends; + if (send_ok) raw.assign(bytes, bytes + length); + return send_ok; + } + float getLastSNR() const { return 2.5f; } + float getLastRSSI() const { return -92; } +}; +struct Clock { uint32_t getMillis() const { return millis(); } }; +struct Dispatcher { + Packet* outbound = nullptr; + Radio radio; + Radio* _radio = &radio; + Clock clock; + Clock* _ms = &clock; + uint8_t outbound_restore_cr = 0; + uint32_t outbound_start = 0, outbound_expiry = 0; + bool restoreOutboundTxOverrides() { return true; } + bool isPacketRadioCurrent(const Packet*) const { return true; } + uint8_t getDefaultTxCodingRate() const { return 5; } + uint32_t futureMillis(uint32_t wait) const { return millis() + wait; } + const char* getLogDateTime() const { return "unused compact timestamp"; } + bool startOutboundTransmit(); +}; + +@TX_METHOD@ +} // namespace mesh + +#define PACKET_LOG_FILE "/packet_log" +struct FakeFS { + unsigned opens = 0, writes = 0, removes = 0; + std::string output; + void remove(const char* name) { + assert(!std::strcmp(name, PACKET_LOG_FILE)); + ++removes; + } +}; +struct File { + FakeFS* fs; + explicit operator bool() const { return true; } + void print(const char* text) { ++fs->writes; fs->output += text; } + void printf(const char* format, ...) { + char text[1024]; + va_list args; + va_start(args, format); + const int length = std::vsnprintf(text, sizeof(text), format, args); + va_end(args); + assert(length >= 0 && size_t(length) < sizeof(text)); + ++fs->writes; + fs->output += text; + } + void close() {} +}; +struct Callbacks { + virtual void setLoggingOn(bool enable) = 0; + virtual void eraseLogFile() = 0; +}; +inline uint8_t getRetryLogCodingRate(const mesh::Packet*, uint8_t fallback) { return fallback; } +inline uint16_t rxPowerSavingPreambleForParams(uint8_t, float) { return 16; } +struct MyMesh : Callbacks { + bool _logging = false; + FakeFS fs; + FakeFS* _fs = &fs; + mesh::Radio radio; + mesh::Radio* _radio = &radio; + unsigned refreshed = 0; + uint8_t active_sf = 7; + float active_bw = 125; + File openAppend(const char* name) { + assert(!std::strcmp(name, PACKET_LOG_FILE)); + ++fs.opens; + return File{&fs}; + } + const char* getLogDateTime() const { return "timestamp"; } + uint8_t getDefaultTxCodingRate() const { return 5; } + void formatFloodRetryPath(char* output, size_t size, const mesh::Packet*) { + std::snprintf(output, size, "01"); + } + bool formatFloodRetryHeard(char* output, size_t size, const mesh::Packet*) { + std::snprintf(output, size, "02"); + return true; + } + void refreshFloodRetryReachability(const mesh::Packet*) { ++refreshed; } + @INLINE_CALLBACKS@ + void logRxRaw(float snr, float rssi, const uint8_t raw[], int len); + void logRx(mesh::Packet* pkt, int len, float score); + void logTx(mesh::Packet* pkt, int len); + void logTxFail(mesh::Packet* pkt, int len); + void onFloodRetryEvent(const char* event, const mesh::Packet* packet, + uint32_t delay_millis, uint8_t retry_attempt); +}; +@RX_METHOD@ +@FILE_METHODS@ + +static void reset() { + log_port = Port{}; + mesh::logging_enabled = true; + mesh::serialLogDroppedCount() = 0; + mesh::serialLogPortSeen() = false; + g_mock_millis = 100; +} +static std::string hex(const uint8_t* bytes, size_t size) { + const char* digits = "0123456789ABCDEF"; + std::string output; + for (size_t i = 0; i < size; ++i) { + output += digits[bytes[i] >> 4]; + output += digits[bytes[i] & 15]; + } + return output; +} +static mesh::Packet makePacket(bool maximum = false) { + mesh::Packet packet; + packet.header = (PAYLOAD_TYPE_GRP_DATA << PH_TYPE_SHIFT) | ROUTE_TYPE_TRANSPORT_DIRECT; + packet.transport_codes[0] = 0x1234; + packet.transport_codes[1] = 0xABCD; + packet.setPathHashSizeAndCount(2, maximum ? 32 : 2); + for (unsigned i = 0; i < packet.getPathByteLen(); ++i) packet.path[i] = uint8_t(i); + packet.payload_len = maximum ? MAX_PACKET_PAYLOAD : 3; + for (unsigned i = 0; i < packet.payload_len; ++i) packet.payload[i] = uint8_t(i ^ 0xAB); + return packet; +} + +int main() { + MyMesh repeater; + // Actual inline callbacks and persistent packet emitters remain independent + // of live USB logging. The file-off control cannot be re-enabled at runtime. + assert(!repeater._logging); + repeater.setLoggingOn(true); + assert(repeater._logging == bool(EXPECT_FILE_LOGGING)); + mesh::Packet stored = makePacket(); + repeater.logRx(&stored, 42, 0.25f); + repeater.logTx(&stored, 42); + repeater.logTxFail(&stored, 42); + repeater.onFloodRetryEvent("good", &stored, 12, 1); + assert(repeater.refreshed == 1); // Reachability is not a logging side effect. + repeater.onFloodRetryEvent("good", nullptr, 12, 1); + repeater.onFloodRetryEvent("failure", &stored, 12, 1); + assert(repeater.refreshed == 1); + repeater.eraseLogFile(); + if (EXPECT_FILE_LOGGING) { + assert(repeater.fs.opens == 5 && repeater.fs.writes > 0 && repeater.fs.removes == 1); + for (const char* marker : {": RX,", ": TX,", ": TX FAIL!", ": FLOOD RETRY good"}) + assert(repeater.fs.output.find(marker) != std::string::npos); + } else { + assert(repeater.fs.opens == 0 && repeater.fs.writes == 0 && repeater.fs.removes == 0); + assert(repeater.fs.output.empty()); + } + repeater.setLoggingOn(false); + assert(!repeater._logging); + const unsigned old_opens = repeater.fs.opens; + repeater.logRx(&stored, 42, 0.25f); + repeater.logTx(&stored, 42); + repeater.logTxFail(&stored, 42); + repeater.onFloodRetryEvent("good", &stored, 12, 1); + assert(repeater.refreshed == 2 && repeater.fs.opens == old_opens); + const uint8_t raw[] = {0x00, 0xAB, 0xFF}; + reset(); + repeater.logRxRaw(2.5f, -92, raw, sizeof(raw)); + assert(log_port.output == "R00ABFF\n"); + assert(g_mock_millis == 100 && mesh::serialLogDroppedCount() == 0); + + uint8_t maximum_rx[MAX_TRANS_UNIT]; + for (unsigned i = 0; i < sizeof(maximum_rx); ++i) maximum_rx[i] = uint8_t(i); + reset(); + repeater.logRxRaw(0, -100, maximum_rx, sizeof(maximum_rx)); + assert(log_port.output == "R" + hex(maximum_rx, sizeof(maximum_rx)) + "\n"); + assert(log_port.output.size() == 2 + MAX_TRANS_UNIT * 2); + + for (const bool maximum : {false, true}) { + mesh::Packet packet = makePacket(maximum); + uint8_t wire[MAX_TRANS_UNIT]; + const size_t length = packet.writeTo(wire); + assert(length == size_t(packet.getRawLength())); + if (maximum) assert(length == 254); + // Exercise real decoding/bounds too: feature gates must not alter packet + // framing, transport codes, path bytes, payload or accepted wire lengths. + mesh::Packet decoded; + assert(decoded.readFrom(wire, uint8_t(length))); + assert(decoded.header == packet.header && decoded.path_len == packet.path_len); + assert(decoded.payload_len == packet.payload_len); + assert(!std::memcmp(decoded.payload, packet.payload, packet.payload_len)); + assert(!decoded.readFrom(wire, 1)); + auto invalid = std::vector(wire, wire + length); + invalid[5] = 0xC1; // reserved four-byte path mode + assert(!decoded.readFrom(invalid.data(), uint8_t(invalid.size()))); + + reset(); + mesh::Dispatcher dispatcher; + dispatcher.outbound = &packet; + assert(dispatcher.startOutboundTransmit()); + assert(dispatcher.radio.raw == std::vector(wire, wire + length)); + assert(log_port.output == "T" + hex(wire, length) + "\n"); + assert(mesh::serialLogDroppedCount() == 0); + + reset(); + mesh::logging_enabled = false; + repeater.logRxRaw(0, -100, maximum_rx, sizeof(maximum_rx)); + assert(dispatcher.startOutboundTransmit()); + assert(log_port.output.empty()); + assert(dispatcher.radio.raw == std::vector(wire, wire + length)); + + reset(); + dispatcher.radio.send_ok = false; + assert(!dispatcher.startOutboundTransmit()); + assert(log_port.output.empty()); // Never claim a TX which did not start. + } + + // An initially undrained host must cost neither time nor a false drop. + reset(); + log_port.stalled = true; + repeater.logRxRaw(0, -100, raw, sizeof(raw)); + assert(log_port.output.empty() && g_mock_millis == 100); + assert(mesh::serialLogDroppedCount() == 0); + + // Once a host was observed, refused RX and TX records count as lost. RF + // transmission still proceeds; recovery exposes the existing DROP marker. + reset(); + repeater.logRxRaw(0, -100, raw, sizeof(raw)); + log_port.output.clear(); + log_port.stalled = true; + repeater.logRxRaw(0, -100, raw, sizeof(raw)); + mesh::Packet packet = makePacket(); + mesh::Dispatcher dispatcher; + dispatcher.outbound = &packet; + assert(dispatcher.startOutboundTransmit() && dispatcher.radio.sends == 1); + assert(log_port.output.empty() && g_mock_millis == 100); + assert(mesh::serialLogDroppedCount() == 2); + log_port.stalled = false; + repeater.logRxRaw(0, -100, raw, sizeof(raw)); + assert(log_port.output == "DROP:2\r\nR00ABFF\n"); + + // Keep the bounded writer's existing mid-record stall behavior explicit. + reset(); + mesh::serialLogPortSeen() = true; + log_port.wedge_after = 14; + repeater.logRxRaw(0, -100, maximum_rx, sizeof(maximum_rx)); + assert(log_port.output.size() == 14); + assert(g_mock_millis - 100 <= SERIAL_LOG_WRITE_BUDGET_MS + 1); + assert(mesh::serialLogDroppedCount() == 1); + std::puts("PASS: production compact repeater RX/TX logging survives all feature gates"); +} +''' + + +class MiniRepeaterLoggingTest(unittest.TestCase): + def test_actual_raw_rx_tx_packet_codec_and_backpressure_with_all_gate_combinations(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "a host C++17 compiler is required") + packet = (ROOT / "src/Packet.cpp").read_text() + packet_methods = "\n".join(body(packet, signature) for signature in ( + "Packet::Packet()", "bool Packet::isValidPathLen(", + "size_t Packet::writePath(", "int Packet::getRawLength() const", + "uint8_t Packet::writeTo(", "bool Packet::readFrom(")) + dispatcher = (ROOT / "src/Dispatcher.cpp").read_text() + repeater = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text() + program = HARNESS.replace("@PACKET_METHODS@", packet_methods).replace( + "@TX_METHOD@", body(dispatcher, "bool Dispatcher::startOutboundTransmit()")) + program = program.replace("@RX_METHOD@", body(repeater, "void MyMesh::logRxRaw(")) + header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text() + program = program.replace("@INLINE_CALLBACKS@", "\n".join(body(header, signature) for signature in ( + "void setLoggingOn(", "void eraseLogFile()"))) + program = program.replace("@FILE_METHODS@", "\n".join(body(repeater, signature) for signature in ( + "void MyMesh::logRx(", "void MyMesh::logTx(", "void MyMesh::logTxFail(", + "void MyMesh::onFloodRetryEvent("))) + sanitizers = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else []) + with tempfile.TemporaryDirectory(prefix="mini-experiment-logging-") as directory: + source, executable = Path(directory) / "test.cpp", Path(directory) / "test" + source.write_text(program, encoding="ascii") + for carrier, management, file_logging in itertools.product((0, 1), repeat=3): + with self.subTest(carrier_tools=carrier, management_reporting=management, + packet_file_logging=file_logging): + command = [compiler, "-std=c++17", "-O1", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-parameter", "-Wno-unused-variable", + "-Wno-unused-but-set-variable", "-Wno-sign-compare", *sanitizers, + "-DARDUINO=1", "-DSTM32_PLATFORM=1", "-DMESH_DEBUG=0", + "-DMESH_PACKET_LOGGING=1", "-DMESH_PACKET_LOGGING_COMPACT=1", + f"-DMESH_ENABLE_CARRIER_TOOLS={carrier}", + f"-DMESH_ENABLE_MANAGEMENT_REPORTING={management}", + f"-DMESH_ENABLE_PACKET_FILE_LOGGING={file_logging}", + f"-DEXPECT_CARRIER={carrier}", f"-DEXPECT_MANAGEMENT={management}", + f"-DEXPECT_FILE_LOGGING={file_logging}", + "-I", str(ROOT / "test/mocks"), "-I", str(ROOT / "src"), + str(source), "-o", str(executable)] + built = subprocess.run(command, capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + tested = subprocess.run([str(executable)], capture_output=True, text=True, timeout=15) + self.assertEqual(tested.returncode, 0, tested.stdout + tested.stderr) + self.assertIn("PASS: production compact repeater RX/TX", tested.stdout) + + def test_baseline_storage_logging_host_cli_and_authentication_contracts_remain(self): + config = configparser.ConfigParser(interpolation=None, inline_comment_prefixes=(";",)) + config.read(ROOT / "variants/wio-e5-mini/platformio.ini") + variant = config["env:wio-e5-mini_repeater"] + self.assertEqual(variant.getint("board_upload.maximum_size"), 245760) + flags = variant["build_flags"] + self.assertRegex(flags, r"-D\s+LFS_FLASH_TOTAL_SIZE=16384\b") + self.assertRegex(flags, r"-D\s+MESH_PACKET_LOGGING_COMPACT=1\b") + self.assertNotRegex(flags, r"-D\s+MESH_ENABLE_HOST_CLI=0\b") + self.assertNotRegex(flags, r"-D\s+MESH_PACKET_LOGGING=0\b") + header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text() + self.assertIn("#ifndef MESH_ENABLE_HOST_CLI\n#define MESH_ENABLE_HOST_CLI 1\n#endif", header) + file_config = (ROOT / "src/helpers/PacketFileLoggingConfig.h").read_text() + self.assertRegex(file_config, r"#ifndef MESH_ENABLE_PACKET_FILE_LOGGING\s+" + r"#define MESH_ENABLE_PACKET_FILE_LOGGING 1\s+#endif") + source = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text() + dispatch = body(source, "void MyMesh::handleCommand(uint32_t sender_timestamp, ClientInfo* sender,") + self.assertLess(dispatch.index("handleReplayResetCommand(sender, command, reply, usb_origin)"), + dispatch.index("_cli.handleCommand(")) + self.assertIn("if (sender != nullptr && sender_timestamp == 0) sender_timestamp = 1;", dispatch) + receive = source[source.index("else if (type == PAYLOAD_TYPE_TXT_MSG && len > 5"):] + self.assertIn("client->isAdmin() || client->isRegionMgr() || client->isFilterMgr()", receive) + self.assertIn("sender_timestamp < client->last_timestamp && !cached_retry", receive) + host_complete = body(source, "bool MyMesh::completeHostCliRequest(") + self.assertIn("client_index < 0 || !acl.getClientByIdx(client_index)->isAdmin()", host_complete) + host_reply = body(source, "bool MyMesh::handleHostCliSerialReply(") + self.assertIn("parsed.request_id != deferred_cli_command.request_id", host_reply) + self.assertIn("parsed.request_nonce != host_cli_nonce", host_reply) + self.assertIn("else if (!host_cli_claimed)", host_reply) + deferred = body(source, "void __attribute__((noinline)) MyMesh::processDeferredCliCommand()") + self.assertIn("if (!client->isAdmin())", deferred) + self.assertIn("self_id.sign(signature, (const uint8_t*)signed_content,", deferred) + common = body((ROOT / "src/helpers/CommonCLI.cpp").read_text(), "void CommonCLI::handleCommand(") + self.assertIn("mesh::handleUsbLoggingWatchdogCommand(", common) + self.assertIn("_radio_profiles.handle(", common) + self.assertIn('#endif\n } else if (sender_timestamp == 0 && memcmp(command, "stats-packets",', common) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_morse_rx.py b/test/test_morse_rx.py index 14342853f..4ca8d1285 100644 --- a/test/test_morse_rx.py +++ b/test/test_morse_rx.py @@ -176,12 +176,15 @@ template struct HasReceiveRead : std::false_type {}; template struct HasReceiveRead> : std::true_type {}; template struct HasReceiveBandwidth : std::false_type {}; template struct HasReceiveBandwidth> : std::true_type {}; +template struct HasReceiveService : std::false_type {}; +template struct HasReceiveService> : std::true_type {}; int main() { static_assert(MESH_ENABLE_MORSE_RX==EXPECTED_RX,"STM32 RX opt-in must be explicit"); static_assert(HasCapturePointer::value==bool(EXPECTED_RX),"disabled RX must exclude capture metadata"); static_assert(HasCaptureAllocator::value==bool(EXPECTED_RX),"disabled RX must exclude allocation machinery"); static_assert(HasCaptureOwner::value==bool(EXPECTED_RX),"disabled RX must exclude ownership state"); static_assert(HasCaptureReadErrors::value==bool(EXPECTED_RX),"disabled RX must exclude read-error state"); + static_assert(HasReceiveService::value==bool(EXPECTED_RX),"disabled RX must exclude its service implementation"); #if MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM) constexpr bool expected_native_hooks=true; #else @@ -203,7 +206,6 @@ int main() { assert(!w.supportsMorseRx()); assert(w.startMorseRx(902.1f,25,60000)==mesh::MorseRxResult::Unsupported); assert(!w.chip.standby_calls && !w.board.awake); - assert(!w.serviceMorseRx()); assert(w.stopMorseRx()==mesh::MorseRxResult::Unsupported); mesh::MorseRxDiagnostics stats; assert(!w.morseRxDiagnostics(stats) && !w.morseCaptureGeneration() && !w.morseCaptureLength()); diff --git a/test/test_radio_profile_cli.py b/test/test_radio_profile_cli.py index f99484b56..92f2ed90c 100644 --- a/test/test_radio_profile_cli.py +++ b/test/test_radio_profile_cli.py @@ -1,5 +1,6 @@ """Run the production shared profile command parser and persistence with a memory FS.""" from pathlib import Path +import itertools import shutil import subprocess import tempfile @@ -14,10 +15,11 @@ class RadioProfileCLITest(unittest.TestCase): with tempfile.TemporaryDirectory() as work: # Compact STM32 images omit fleet staging. Both paths must preserve # local schedules, persistence retries and temporary lease bounds. - for fleet_enabled in (False, True): - exe = Path(work) / f'profiles-{int(fleet_enabled)}.exe' + for fleet_enabled, carrier_enabled in itertools.product((False, True), repeat=2): + exe = Path(work) / f'profiles-{int(fleet_enabled)}-{int(carrier_enabled)}.exe' subprocess.run([compiler, '-std=c++17', '-Wall', '-Wextra', f'-DMESH_ENABLE_FLEET_CONTROL={int(fleet_enabled)}', + f'-DMESH_ENABLE_CARRIER_TOOLS={int(carrier_enabled)}', '-I', str(ROOT/'test/fixtures/radio_profiles/mocks'), '-I', str(ROOT/'test/mocks'), '-I', str(ROOT/'src'), str(ROOT/'src/helpers/RadioProfileCLI.cpp'), @@ -26,7 +28,8 @@ class RadioProfileCLITest(unittest.TestCase): for scenario in (None, 'permanent_order', 'temporary_boundary', 'lease_creation', 'save_failure', 'schedule_permutations', 'storage_isolation', 'relative_schedule'): - with self.subTest(fleet=fleet_enabled, scenario=scenario or 'commands_persistence_and_timers'): + with self.subTest(fleet=fleet_enabled, carrier=carrier_enabled, + scenario=scenario or 'commands_persistence_and_timers'): subprocess.run([str(exe)] + ([scenario] if scenario else []), check=True) if __name__ == '__main__': diff --git a/test/test_stm32_companion_profile.py b/test/test_stm32_companion_profile.py index b8f32ba96..a6b28f953 100644 --- a/test/test_stm32_companion_profile.py +++ b/test/test_stm32_companion_profile.py @@ -21,6 +21,7 @@ class STM32CompanionProfileTest(unittest.TestCase): variant = "rak3x72" target = "RAK_3x72_companion_radio_usb" base = "rak3x72" + extra_timer_targets = () def setUp(self): self.config = configparser.ConfigParser( @@ -28,12 +29,16 @@ class STM32CompanionProfileTest(unittest.TestCase): self.config.read(ROOT / "variants" / self.variant / "platformio.ini") self.companion = self.config["env:" + self.target] - def test_unused_timer_wrappers_are_removed_only_from_headless_companion(self): + def test_unused_timer_wrappers_are_removed_only_from_approved_headless_roles(self): for flag in ("RADIOLIB_TONE_UNSUPPORTED", "HAL_TIM_MODULE_ONLY"): self.assertRegex(self.companion["build_flags"], r"-D\s+" + flag + r"\b") for section in self.config.sections(): if section != self.companion.name: - self.assertNotIn(flag, self.config[section].get("build_flags", "")) + flags = self.config[section].get("build_flags", "") + if section in self.extra_timer_targets: + self.assertRegex(flags, r"-D\s+" + flag + r"\b") + else: + self.assertNotIn(flag, flags) def test_flash_and_filesystem_boundaries_do_not_move(self): self.assertNotIn("board_upload.maximum_size", self.companion) @@ -93,6 +98,7 @@ class WioSTM32CompanionProfileTest(STM32CompanionProfileTest): variant = "wio-e5-mini" target = "wio-e5-mini_companion_radio_usb" base = "lora_e5_mini" + extra_timer_targets = ("env:wio-e5-mini_repeater",) def test_headless_button_controls_are_retained(self): self.assertRegex(self.companion["build_flags"], diff --git a/test/test_stm32_mini_repeater_profile.py b/test/test_stm32_mini_repeater_profile.py new file mode 100644 index 000000000..292247c4c --- /dev/null +++ b/test/test_stm32_mini_repeater_profile.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Guard the constrained Mini Repeater recipe without bypassing size/storage gates.""" + +import configparser +import re +import runpy +import shlex +import subprocess +import textwrap +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +TARGET = "wio-e5-mini_repeater" +SECTION = "env:" + TARGET +LINK_SIZE_FLAGS = ( + "-fno-caller-saves", + "-fno-tree-dominator-opts", + "-fno-reorder-blocks", + "-fno-reorder-functions", + "-fno-tree-switch-conversion", +) +OPTIONAL_CUTS = ( + "MESH_ENABLE_CARRIER_TOOLS", + "MESH_ENABLE_MANAGEMENT_REPORTING", + "MESH_ENABLE_PACKET_FILE_LOGGING", +) + + +def read_config(path): + config = configparser.ConfigParser( + interpolation=None, inline_comment_prefixes=(";",)) + config.read(path) + return config + + +class FakeEnvironment(dict): + def Append(self, **values): + for name, items in values.items(): + self.setdefault(name, []).extend(items) + + def AppendUnique(self, **values): + for name, items in values.items(): + current = self.setdefault(name, []) + current.extend(item for item in items if item not in current) + + +class MiniRepeaterProfileTest(unittest.TestCase): + def setUp(self): + self.config = read_config(ROOT / "variants/wio-e5-mini/platformio.ini") + self.repeater = self.config[SECTION] + self.flags = self.repeater["build_flags"] + + def test_existing_flash_filesystem_and_neighbour_contracts_are_retained(self): + self.assertEqual(self.repeater.getint("board_upload.maximum_size"), 245760) + self.assertRegex(self.flags, r"-D\s+LFS_FLASH_TOTAL_SIZE=16384\b") + self.assertRegex(self.flags, r"-D\s+MAX_NEIGHBOURS=50\b") + self.assertEqual(245760 + 16384, 256 * 1024) + self.assertEqual(self.repeater["platform_packages"].split(), [ + "platformio/toolchain-gccarmnoneeabi@^1.140201.0"]) + self.assertEqual(self.repeater["build_unflags"].split(), ["-Os"]) + self.assertIn("+<../examples/simple_repeater/*.cpp>", + self.repeater["build_src_filter"]) + self.assertNotIn("MAX_CLIENTS", self.flags) + self.assertNotIn("--no-check", self.repeater.get("extra_scripts", "")) + + def test_three_opt_outs_apply_only_to_exact_mini_repeater(self): + for macro in OPTIONAL_CUTS: + self.assertRegex(self.flags, r"-D\s+" + macro + r"=0\b") + for path in [ROOT / "platformio.ini", *sorted((ROOT / "variants").rglob("platformio.ini"))]: + config = read_config(path) + for name in config.sections(): + flags = config[name].get("build_flags", "") + for macro in OPTIONAL_CUTS: + with self.subTest(path=path.relative_to(ROOT), section=name, macro=macro): + if path == ROOT / "variants/wio-e5-mini/platformio.ini" and name == SECTION: + continue + self.assertNotRegex(flags, r"-D\s*" + macro + r"=0\b") + # Retain the profile's pre-existing trims, rather than adding unrelated cuts. + disabled = set(re.findall(r"-D\s*(MESH_ENABLE_\w+)=0\b", self.flags)) + self.assertEqual(disabled, {*OPTIONAL_CUTS, "MESH_ENABLE_CLOCK_SYNC", + "MESH_ENABLE_FLOOD_GROUP_MODERATION"}) + + def test_host_cli_live_logging_and_existing_core_features_remain(self): + self.assertNotIn("MESH_ENABLE_HOST_CLI", self.flags) + header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text() + self.assertRegex(header, r"#ifndef MESH_ENABLE_HOST_CLI\s+" + r"#define MESH_ENABLE_HOST_CLI 1\b") + self.assertNotRegex(self.flags, r"-D\s*(?:MESH_PACKET_LOGGING|MESH_USB_LOGGING_MERGED)=0\b") + self.assertRegex(self.flags, r"-D\s+MESH_PACKET_LOGGING_COMPACT=1\b") + self.assertRegex(self.flags, r"-D\s+MESH_ENABLE_RECENT_REPEATERS=1\b") + # Numeric shortcut GET is deliberately not part of this size profile. + self.assertNotIn("NUMERIC", self.flags) + + def test_compact_crypto_and_unused_timer_wrappers_are_explicit(self): + for macro in ("ED25519_COMPACT_SHA512=1", "ED25519_COMPACT_SCALAR=1", + "ED25519_COMPACT_FIELD=1", "RADIOLIB_TONE_UNSUPPORTED", + "HAL_TIM_MODULE_ONLY"): + self.assertRegex(self.flags, r"-D\s+" + macro + r"\b") + common = read_config(ROOT / "platformio.ini") + self.assertRegex(common["stm32_base"]["build_flags"], + r"-D\s+ED25519_COMPACT_BASE=1\b") + self.assertIn("post:scripts/check_firmware_ram.py", + common["stm32_base"]["extra_scripts"]) + + def test_measured_compiler_flags_reach_both_compile_and_lto_link(self): + tokens = shlex.split(self.flags) + for flag in ("-Oz", "-flto", *LINK_SIZE_FLAGS): + self.assertIn(flag, tokens) + scripts = self.repeater["extra_scripts"].split() + self.assertIn("${stm32_base.extra_scripts}", scripts) + self.assertIn("post:scripts/stm32_mini_size.py", scripts) + helper = ROOT / "scripts/stm32_mini_size.py" + for target in (TARGET, "wio-e5-mini_sensor", "wio-e5-mini_companion_radio_usb", + "RAK_3x72_companion_radio_usb", "wio-e5_repeater"): + with self.subTest(target=target): + environment = FakeEnvironment(PIOENV=target, LINKFLAGS=["keep-existing-flag"]) + runpy.run_path(str(helper), init_globals={ + "env": environment, + "Import": lambda name: self.assertEqual(name, "env"), + }) + expected = ["keep-existing-flag", *(LINK_SIZE_FLAGS if target == TARGET else ())] + self.assertEqual(environment["LINKFLAGS"], expected) + self.assertEqual(set(environment), {"PIOENV", "LINKFLAGS"}) + + def test_capability_contract_declares_only_exact_mini_repeater_omissions(self): + command = r''' +set -euo pipefail +source build.sh +pio() { echo 'FORBIDDEN PlatformIO call in host capability test' >&2; return 99; } +target="$1" +BUILD_PROFILE_FOR_TARGET="$2" +PIO_CONFIG_JSON='[]' +PIO_ENV_PLATFORM_BY_NAME["$target"]=STM32_PLATFORM +PIO_ENV_BOARD_BY_NAME["$target"]=lora_e5_mini +BUILD_CAPABILITIES=() +BUILD_EXPECTATIONS=() +BUILD_APPLICATION_EXPECTATIONS=() +BUILD_REDUCTIONS=(caller.record) +PLATFORMIO_BUILD_FLAGS=-DKEEP_CALLER_FLAGS=1 +PLATFORMIO_BUILD_UNFLAGS=-DKEEP_CALLER_UNFLAGS=1 +declare_build_capability_contract "$target" STM32_PLATFORM +[ "$PLATFORMIO_BUILD_FLAGS" = -DKEEP_CALLER_FLAGS=1 ] +[ "$PLATFORMIO_BUILD_UNFLAGS" = -DKEEP_CALLER_UNFLAGS=1 ] +printf '%s\n' "${BUILD_REDUCTIONS[@]}" +''' + omissions = [ + "radio.carrier_tools omitted to preserve the Mini Repeater app boundary", + "management.reporting and data.tx omitted to preserve the Mini Repeater app boundary", + "logging.file omitted; live USB/MQTT packet logging retained", + ] + for target in (TARGET, "wio-e5-mini_companion_radio_usb", "wio-e5-mini_sensor", + "wio-e5_repeater", "RAK_3x72_repeater", TARGET + "_custom"): + for profile in ("standard", "full"): + with self.subTest(target=target, profile=profile): + result = subprocess.run( + ["bash", "-c", command, "mini-capability-contract", target, profile], + cwd=ROOT, capture_output=True, text=True, check=True, timeout=10) + self.assertEqual(result.stderr, "") + self.assertEqual(result.stdout.splitlines(), + ["caller.record", *(omissions if target == TARGET else ())]) + + def test_ci_links_exact_cable_only_profile_with_release_logging_and_gates(self): + workflow = (ROOT / ".github/workflows/run-unit-tests.yml").read_text() + job = re.search(r"^ stm32-mini-repeater-smoke:\n(.*?)(?=^ [\w-]+:|\Z)", + workflow, re.MULTILINE | re.DOTALL) + self.assertIsNotNone(job) + script = job[1].replace("\\\n", "") + self.assertIn("set -euo pipefail", script) + self.assertIn("bash build.sh build-firmware " + TARGET, script) + self.assertIn("--firmware-version v1.18.0.1-halo-keymind-cascade-dev", script) + self.assertIn("--radio-preset usa-cascade-fixed --profile cascade", script) + self.assertIn("--allow-no-ota", script) + self.assertIn("DISABLE_DEBUG: '0'", script) + self.assertNotIn("--require-ota", script) + self.assertNotIn("pio run", script) + self.assertNotIn(" &", script) + self.assertNotRegex(script, r"continue-on-error|maximum_size|LFS_FLASH_TOTAL_SIZE|MAX_NEIGHBOURS") + for test in ("test_stm32_mini_repeater_profile.py", "test_mini_repeater_logging.py", + "test_management_feature_gate.py", "test_carrier_wave.py"): + self.assertIn("python3 -B test/" + test + " -v", script) + command = textwrap.dedent(job[1].rsplit(" run: |\n", 1)[1]) + subprocess.run(["bash", "-n"], input=command, text=True, check=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/variants/wio-e5-mini/platformio.ini b/variants/wio-e5-mini/platformio.ini index 01ade17b4..a302c8e01 100644 --- a/variants/wio-e5-mini/platformio.ini +++ b/variants/wio-e5-mini/platformio.ini @@ -21,13 +21,27 @@ lib_deps = ${stm32_base.lib_deps} extends = lora_e5_mini board_upload.maximum_size = 245760 ; 240 KiB app, 16 KiB LittleFS ; GCC 14.2 emits a materially smaller LTO image than the platform's GCC 12 -; default, preserving the deployed filesystem boundary without cutting roles. +; default. Retain it rather than adopting the experimental GCC 15 toolchain. platform_packages = platformio/toolchain-gccarmnoneeabi@^1.140201.0 build_unflags = -Os +extra_scripts = ${stm32_base.extra_scripts} + post:scripts/stm32_mini_size.py build_flags = ${lora_e5_mini.build_flags} - -Oz ; GCC 12's strongest size profile preserves the deployed 16 KiB filesystem boundary + ; Equivalent compact arithmetic and unused timer wrappers leave room for the + ; routing, host-service bridge and live USB/MQTT logging on this 256 KiB part. + -D ED25519_COMPACT_SHA512=1 + -D ED25519_COMPACT_SCALAR=1 + -D ED25519_COMPACT_FIELD=1 + -D RADIOLIB_TONE_UNSUPPORTED + -D HAL_TIM_MODULE_ONLY + -Oz -flto + -fno-caller-saves + -fno-tree-dominator-opts + -fno-reorder-blocks + -fno-reorder-functions + -fno-tree-switch-conversion -fno-inline-small-functions ; reduce flash without moving the deployed filesystem boundary -fipa-pta -fmerge-all-constants @@ -44,6 +58,10 @@ build_flags = ${lora_e5_mini.build_flags} -D MESH_ENABLE_CLOCK_SYNC=0 ; preserve the 16 KiB filesystem on this 256 KiB part -D MESH_ENABLE_FLOOD_GROUP_MODERATION=0 ; keep core routing and radio scheduling in flash -D MESH_PACKET_LOGGING_COMPACT=1 ; retain complete RX/TX bytes without the duplicate parsed RX line + ; User-approved Mini Repeater cuts only; other boards keep their defaults. + -D MESH_ENABLE_CARRIER_TOOLS=0 + -D MESH_ENABLE_MANAGEMENT_REPORTING=0 + -D MESH_ENABLE_PACKET_FILE_LOGGING=0 -D MESH_ENABLE_RECENT_REPEATERS=1 -D LORA_TX_POWER=22 -D ADVERT_NAME='"wio-e5-mini Repeater"'