Fit Mini Repeater with approved optional service cuts

This commit is contained in:
mikecarper committed 2026-10-17 19:35:05 -07:00
1 parent 7a9d099039
commit 55f67f9062
30 files changed
+1259 -34

No files matched your search

+25
View File
@@ -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:
+10
View File
@@ -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"
+4
View File
@@ -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
+8 -1
View File
@@ -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 <MHz>` 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`
---
+4
View File
@@ -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
+50
View File
@@ -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.
+29 -3
View File
@@ -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
+11 -1
View File
@@ -3,6 +3,7 @@
#include <Arduino.h>
#include <new>
#include <Mesh.h>
#include <helpers/PacketFileLoggingConfig.h>
#include <helpers/PowerSavingDiagnostics.h>
#if defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
@@ -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;
+15
View File
@@ -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",
])
+11
View File
@@ -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
+10
View File
@@ -1,4 +1,6 @@
#include <Arduino.h>
#include "CarrierTools.h"
#include "PacketFileLoggingConfig.h"
#include "CommonCLI.h"
#include "PrefsSaveReplyGuard.h"
#include "UsbLoggingWatchdog.h"
@@ -6,7 +8,9 @@
#include "HilStartupTrace.h"
#include <helpers/ui/DisplayPowerSettings.h>
#include <helpers/BatteryChargeCLI.h>
#if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX
#include <helpers/MorseCLI.h>
#endif
#include <helpers/MorseLoopPolicy.h>
#if defined(RAK_4631)
#include <helpers/PeripheralModePrefs.h>
@@ -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] == ' ')) {
+53 -1
View File
@@ -1,10 +1,12 @@
#include "ManagementFeatureConfig.h"
#include "CommonCLI.h"
#include <new>
#if MESH_ENABLE_MANAGEMENT_REPORTING
#include "ManagementReporter.h"
#include "FileRead.h"
#include "FilePresence.h"
#include "PersistentStoreFormat.h"
#include "HilStartupTrace.h"
#include <new>
#include <stdlib.h>
#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
+11
View File
@@ -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
+3
View File
@@ -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
+11
View File
@@ -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
+5
View File
@@ -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;
@@ -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 <math.h>
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<CustomSTM32WLx*>(_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<CustomSTM32WLx*>(_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<CustomSTM32WLx*>(_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
+33 -5
View File
@@ -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<uint32_t>(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<uint32_t>(millis()));
#endif
@@ -1770,7 +1791,9 @@ bool RadioLibWrapper::stopCarrierWave() {
_cw_retry_at = static_cast<uint32_t>(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<uint32_t>(now));
+29
View File
@@ -1,6 +1,7 @@
#pragma once
#include "NoiseFloorEstimator.h"
#include "../CarrierTools.h"
#include <Mesh.h>
#include <RadioLib.h>
@@ -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;
+7 -2
View File
@@ -9,6 +9,7 @@ from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
HARNESS = r'''
#include <Arduino.h>
#include <helpers/CarrierTools.h>
#include <helpers/CarrierWaveCLI.h>
#include <cassert>
#include <helpers/radiolib/RadioInterruptEvents.h>
@@ -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])
+2 -1
View File
@@ -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]"),
])
+2 -1
View File
@@ -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]"),
])
+74 -11
View File
@@ -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 <vector>
#include <cstdint>
#include <helpers/FileRead.h>
#include <helpers/PacketFileLoggingConfig.h>
@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()
+197
View File
@@ -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 <cassert>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#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 = &region;
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 <string>
#include <vector>
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<char> 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()
+416
View File
@@ -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 <Arduino.h>
inline uint32_t micros() { return millis() * 1000U; }
#include <Packet.h>
#include <helpers/CarrierTools.h>
#include <helpers/ManagementFeatureConfig.h>
#include <helpers/PacketFileLoggingConfig.h>
#include <helpers/SerialPacketLog.h>
#include <algorithm>
#include <cassert>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <limits>
#include <string>
#include <vector>
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<size_t>::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<const char*>(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<uint8_t> 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<uint8_t>(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<uint8_t>(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<uint8_t>(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()
+3 -1
View File
@@ -176,12 +176,15 @@ template<typename T,typename=void> struct HasReceiveRead : std::false_type {};
template<typename T> struct HasReceiveRead<T,std::void_t<decltype(&T::readMorseRssi)>> : std::true_type {};
template<typename T,typename=void> struct HasReceiveBandwidth : std::false_type {};
template<typename T> struct HasReceiveBandwidth<T,std::void_t<decltype(&T::morseReceiverBandwidthKHz)>> : std::true_type {};
template<typename T,typename=void> struct HasReceiveService : std::false_type {};
template<typename T> struct HasReceiveService<T,std::void_t<decltype(&T::serviceMorseRx)>> : std::true_type {};
int main() {
static_assert(MESH_ENABLE_MORSE_RX==EXPECTED_RX,"STM32 RX opt-in must be explicit");
static_assert(HasCapturePointer<STMReceive>::value==bool(EXPECTED_RX),"disabled RX must exclude capture metadata");
static_assert(HasCaptureAllocator<STMReceive>::value==bool(EXPECTED_RX),"disabled RX must exclude allocation machinery");
static_assert(HasCaptureOwner<STMReceive>::value==bool(EXPECTED_RX),"disabled RX must exclude ownership state");
static_assert(HasCaptureReadErrors<STMReceive>::value==bool(EXPECTED_RX),"disabled RX must exclude read-error state");
static_assert(HasReceiveService<STMReceive>::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());
+6 -3
View File
@@ -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__':
+8 -2
View File
@@ -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"],
+188
View File
@@ -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()
+20 -2
View File
@@ -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"'