diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 6c6449eda..1462194ca 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -6426,6 +6426,105 @@ bool MyMesh::handleDirectOtaCommand(const char* command, uint32_t timestamp, } #endif +__attribute__((noinline)) +void MyMesh::handleSetChannelFrame(const uint8_t* cmd_frame) { + uint8_t channel_idx = cmd_frame[1]; + ChannelDetails previous{}; + ChannelDetails channel; + StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32); + memset(channel.channel.secret, 0, sizeof(channel.channel.secret)); + memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported + const auto alias = parseCompanionChannelAlias(channel.name); + if (alias.kind == CompanionChannelKind::Invalid) { + writeErrFrame(ERR_CODE_ILLEGAL_ARG); + return; + } +#if MESH_ENABLE_MESHTEXT_CHAT + // Legacy apps fetch only 16 bytes. A label-only edit must not silently + // truncate a provisioned MT AES-256 key to AES-128. An explicit 32-byte SET + // (zero upper half for AES-128) is the unambiguous way to replace that key. + if (alias.kind == CompanionChannelKind::Meshtext + && getChannelForSave(channel_idx, previous) && isMeshtextChannel(previous) + && !memcmp(channel.channel.secret, previous.channel.secret, 16)) { + memcpy(channel.channel.secret + 16, previous.channel.secret + 16, 16); + } + if (!validateMeshtextChannelChange(channel_idx, channel)) { + writeErrFrame(ERR_CODE_BAD_STATE); // Conflicting preset, manual RF, region or busy owner. + return; + } +#else + if (alias.kind == CompanionChannelKind::Meshtext && isAliasRadio2Defined()) { + writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // Meshtext transport not implemented yet. + return; + } +#endif + const bool have_previous = getChannelForSave(channel_idx, previous); + ChannelDetails wire_channel = channel; + ChannelDetails previous_wire_channel = previous; + resolveChannelAlias(wire_channel); + if (have_previous) resolveChannelAlias(previous_wire_channel); + if (have_previous && channel.name[0] != 0 && previous.name[0] != 0 + && !memcmp(wire_channel.channel.secret, previous_wire_channel.channel.secret, sizeof(channel.channel.secret))) { + channel.channel.tx_radio = previous.channel.tx_radio; + } + const char* preset = companionChannelRadio2Preset(channel.name); + const char* previous_preset = companionChannelRadio2Preset(previous.name); + // A join or rename selects the saved preset, just like set radio2. App + // replays/key edits must not undo a later manual Off or custom RF setting. + const bool setup_preset = have_previous && preset + && (!previous_preset || strcmp(preset, previous_preset)); + if (setup_preset) { +#if !MESH_ENABLE_MESHTEXT_CHAT + // Even without the foreign-protocol backend, do not turn old fallback + // mt- channels into inactive protocol requests as a hidden side effect. + for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { + ChannelDetails other; + if (i != channel_idx && BaseChatMesh::getChannel(i, other) + && parseCompanionChannelAlias(other.name).kind == CompanionChannelKind::Meshtext) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + } +#endif + if (!_radio_profiles.stageChannelPreset(preset)) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + } + if (!have_previous + || !setChannel(channel_idx, channel)) { + if (setup_preset) _radio_profiles.finishChannelPreset(false); + writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx + } else if (saveChannels()) { + if (setup_preset && !_radio_profiles.finishChannelPreset(true)) { + // Never report a complete setup when the RF image did not commit. + // Restore the channel when storage permits; report failure even if + // an indeterminate channels write has quarantined further saves. + setChannel(channel_idx, previous); + saveChannels(); + _radio_profiles.finishChannelPreset(false); + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + return; + } +#if MESH_ENABLE_MESHTEXT_CHAT + _mt_seen.clear(); + _mt_profile_dirty = true; + refreshMeshtextProfile(); +#endif + writeOKFrame(); + // Local history seed only after the durable save and its acknowledgement. + seedEmergencyChannelNotice(channel_idx, previous); + } else { + // AtomicFileWriter preserves the last complete channels file. Keep the + // live table consistent with it when a full or failing filesystem cannot + // commit this update, and never acknowledge a change that will disappear + // after reboot. + setChannel(channel_idx, previous); + if (setup_preset) _radio_profiles.finishChannelPreset(false); + writeErrFrame(ERR_CODE_FILE_IO_ERROR); + } +} + void MyMesh::handleCmdFrame(size_t len) { if (len == 0) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); @@ -7453,101 +7552,7 @@ void MyMesh::handleCmdFrame(size_t len) { writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); #endif } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) { - uint8_t channel_idx = cmd_frame[1]; - ChannelDetails previous{}; - ChannelDetails channel; - StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32); - memset(channel.channel.secret, 0, sizeof(channel.channel.secret)); - memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // NOTE: only 128-bit supported - const auto alias = parseCompanionChannelAlias(channel.name); - if (alias.kind == CompanionChannelKind::Invalid) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - return; - } -#if MESH_ENABLE_MESHTEXT_CHAT - // Legacy apps fetch only 16 bytes. A label-only edit must not silently - // truncate a provisioned MT AES-256 key to AES-128. An explicit 32-byte SET - // (zero upper half for AES-128) is the unambiguous way to replace that key. - if (alias.kind == CompanionChannelKind::Meshtext - && getChannelForSave(channel_idx, previous) && isMeshtextChannel(previous) - && !memcmp(channel.channel.secret, previous.channel.secret, 16)) { - memcpy(channel.channel.secret + 16, previous.channel.secret + 16, 16); - } - if (!validateMeshtextChannelChange(channel_idx, channel)) { - writeErrFrame(ERR_CODE_BAD_STATE); // Conflicting preset, manual RF, region or busy owner. - return; - } -#else - if (alias.kind == CompanionChannelKind::Meshtext && isAliasRadio2Defined()) { - writeErrFrame(ERR_CODE_UNSUPPORTED_CMD); // Meshtext transport not implemented yet. - return; - } -#endif - const bool have_previous = getChannelForSave(channel_idx, previous); - ChannelDetails wire_channel = channel; - ChannelDetails previous_wire_channel = previous; - resolveChannelAlias(wire_channel); - if (have_previous) resolveChannelAlias(previous_wire_channel); - if (have_previous && channel.name[0] != 0 && previous.name[0] != 0 - && !memcmp(wire_channel.channel.secret, previous_wire_channel.channel.secret, sizeof(channel.channel.secret))) { - channel.channel.tx_radio = previous.channel.tx_radio; - } - const char* preset = companionChannelRadio2Preset(channel.name); - const char* previous_preset = companionChannelRadio2Preset(previous.name); - // A join or rename selects the saved preset, just like set radio2. App - // replays/key edits must not undo a later manual Off or custom RF setting. - const bool setup_preset = have_previous && preset - && (!previous_preset || strcmp(preset, previous_preset)); - if (setup_preset) { -#if !MESH_ENABLE_MESHTEXT_CHAT - // Even without the foreign-protocol backend, do not turn old fallback - // mt- channels into inactive protocol requests as a hidden side effect. - for (int i = 0; i < MAX_GROUP_CHANNELS; ++i) { - ChannelDetails other; - if (i != channel_idx && BaseChatMesh::getChannel(i, other) - && parseCompanionChannelAlias(other.name).kind == CompanionChannelKind::Meshtext) { - writeErrFrame(ERR_CODE_BAD_STATE); - return; - } - } -#endif - if (!_radio_profiles.stageChannelPreset(preset)) { - writeErrFrame(ERR_CODE_BAD_STATE); - return; - } - } - if (!have_previous - || !setChannel(channel_idx, channel)) { - if (setup_preset) _radio_profiles.finishChannelPreset(false); - writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx - } else if (saveChannels()) { - if (setup_preset && !_radio_profiles.finishChannelPreset(true)) { - // Never report a complete setup when the RF image did not commit. - // Restore the channel when storage permits; report failure even if - // an indeterminate channels write has quarantined further saves. - setChannel(channel_idx, previous); - saveChannels(); - _radio_profiles.finishChannelPreset(false); - writeErrFrame(ERR_CODE_FILE_IO_ERROR); - return; - } -#if MESH_ENABLE_MESHTEXT_CHAT - _mt_seen.clear(); - _mt_profile_dirty = true; - refreshMeshtextProfile(); -#endif - writeOKFrame(); - // Local history seed only after the durable save and its acknowledgement. - seedEmergencyChannelNotice(channel_idx, previous); - } else { - // AtomicFileWriter preserves the last complete channels file. Keep the - // live table consistent with it when a full or failing filesystem cannot - // commit this update, and never acknowledge a change that will disappear - // after reboot. - setChannel(channel_idx, previous); - if (setup_preset) _radio_profiles.finishChannelPreset(false); - writeErrFrame(ERR_CODE_FILE_IO_ERROR); - } + handleSetChannelFrame(cmd_frame); } else if (cmd_frame[0] == CMD_SIGN_START) { BaseSerialInterface* signing_route = _serial->captureReplyRoute(); if (signing_route == NULL) { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 1d0b85818..e8ac56423 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -332,6 +332,7 @@ public: // constrained STM32 targets; merging their branches increases code size. __attribute__((noinline)) #endif + void handleSetChannelFrame(const uint8_t* cmd_frame); void handleCmdFrame(size_t len); bool advert(); void enterCLIRescue(); diff --git a/scripts/stm32_mini_size.py b/scripts/stm32_mini_size.py index 82f2087cd..79cb33378 100644 --- a/scripts/stm32_mini_size.py +++ b/scripts/stm32_mini_size.py @@ -6,6 +6,18 @@ Import("env") # 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": + import importlib.util + from pathlib import Path + + # Reuse the qualified RAK CLI's equivalent libc reply copies. Restrict + # this flag to CommonCLI.cpp; radio, framework and other objects retain + # the existing optimizer recipe. + source = Path(__file__).resolve().with_name("nrf52_flash_trim.py") + spec = importlib.util.spec_from_file_location("mesh_mini_cli_replies", source) + replies = importlib.util.module_from_spec(spec) + spec.loader.exec_module(replies) + env.AddBuildMiddleware(replies.compile_shared_cli_replies, "*CommonCLI.cpp") + env.Append(LINKFLAGS=[ "-fno-caller-saves", "-fno-tree-dominator-opts", diff --git a/test/fixtures/companion_meshtext.cpp b/test/fixtures/companion_meshtext.cpp index 70103d83b..76a9b8a62 100644 --- a/test/fixtures/companion_meshtext.cpp +++ b/test/fixtures/companion_meshtext.cpp @@ -206,6 +206,7 @@ public: void writeErrFrame(uint8_t value) { error=value; } void writeOKFrame() { ok=true; } bool sendGroupMessage(uint32_t,mesh::GroupChannel&,const char*,const char*,int) { ++meshcore_sends; return true; } + void handleSetChannelFrame(const uint8_t*); void command(uint8_t* cmd_frame,int len); void commit(int index,const ChannelDetails& value) { assert(setChannel(index,value)); _mt_profile_dirty=true; refreshMeshtextProfile(); diff --git a/test/fixtures/companion_rxps_setters.cpp b/test/fixtures/companion_rxps_setters.cpp index 970fd247b..0df9d5810 100644 --- a/test/fixtures/companion_rxps_setters.cpp +++ b/test/fixtures/companion_rxps_setters.cpp @@ -9,6 +9,7 @@ #include #include #include +#include "usb_mode_console.h" namespace mesh {enum class RadioParamApplyResult {APPLIED,BUSY,FAILED};} struct Prefs { @@ -40,7 +41,7 @@ struct Driver { } } radio_driver; struct BaseSerialInterface {}; -struct Serial { +struct SerialTransport { BaseSerialInterface requester, other; BaseSerialInterface* current = &requester; BaseSerialInterface* reply_route = nullptr; @@ -54,8 +55,8 @@ struct Serial { }; struct MyMesh { Prefs _prefs, durable; - Serial serial; - Serial* _serial = &serial; + SerialTransport serial; + SerialTransport* _serial = &serial; bool _radio_available = true, storage_accepts = true, saved_radio_apply_pending = false; bool _primary_radio_blocked=false; unsigned saves = 0; diff --git a/test/fixtures/nrf52_bootloader_version/test_companion.cpp b/test/fixtures/nrf52_bootloader_version/test_companion.cpp index 65f1ef5f7..a04cb2220 100644 --- a/test/fixtures/nrf52_bootloader_version/test_companion.cpp +++ b/test/fixtures/nrf52_bootloader_version/test_companion.cpp @@ -9,6 +9,7 @@ #include #include #include +#include "../usb_mode_console.h" struct Board { #if defined(NRF52_PLATFORM) @@ -31,7 +32,7 @@ struct Board { #endif }; struct BaseSerialInterface { unsigned id; }; -struct Serial { +struct SerialTransport { BaseSerialInterface usb{1}, ble{2}; BaseSerialInterface* current = &usb; BaseSerialInterface* captured = nullptr; @@ -62,8 +63,8 @@ struct Output { }; struct MyMesh { Board board; - Serial serial; - Serial* _serial = &serial; + SerialTransport serial; + SerialTransport* _serial = &serial; Output output; char reply_buf[160] = {}; uint8_t out_frame[MAX_FRAME_SIZE] = {}; @@ -142,6 +143,9 @@ static void dispatch(MyMesh& mesh, BaseSerialInterface* requester, bool nul) { } int main() { MyMesh mesh; + char usb_reply[160] = {}; + assert(mesh.handleCommand("AB|get usb.mode", 0, usb_reply)); + assert(std::string(usb_reply) == std::string("AB|usb.mode ") + mesh::usbModeName()); bounds(mesh); #if defined(NRF52_PLATFORM) assert(mesh.board.reader_capacity == 128); diff --git a/test/fixtures/usb_mode_console.h b/test/fixtures/usb_mode_console.h new file mode 100644 index 000000000..1c2e6a2a9 --- /dev/null +++ b/test/fixtures/usb_mode_console.h @@ -0,0 +1,23 @@ +#pragma once + +// Dispatch fixtures have no Arduino console, but must retain the real USB-mode +// handler. Model its backend types separately from each fixture's framed reply +// transport; no console reads, writes or USB peripherals are needed here. +struct HardwareSerial {}; +struct HWCDC {}; +struct USBCDC {}; +struct Adafruit_USBD_CDC {}; +#if (defined(ESP32) || defined(ESP32_PLATFORM)) \ + && defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT + #if defined(ARDUINO_USB_MODE) && ARDUINO_USB_MODE == 1 +inline HWCDC Serial; + #else +inline USBCDC Serial; + #endif +#elif defined(NRF52_PLATFORM) && defined(USE_TINYUSB) +inline Adafruit_USBD_CDC Serial; +#else +inline HardwareSerial Serial; +#endif + +#include diff --git a/test/test_companion_channel_alias.py b/test/test_companion_channel_alias.py index 39acaf50c..f70154efd 100644 --- a/test/test_companion_channel_alias.py +++ b/test/test_companion_channel_alias.py @@ -404,6 +404,7 @@ struct Serial { ''' COMMAND_MEMBERS = r''' + void handleSetChannelFrame(const uint8_t*); Serial serial; Serial* _serial = &serial; struct ProfileSetup { bool stage_ok = true, commit_ok = true, pending = false; @@ -1027,6 +1028,7 @@ class CompanionChannelAliasTest(unittest.TestCase): "if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32)", "if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16)", ) + methods += "\n" + body(companion, "void MyMesh::handleSetChannelFrame(") branches = " else ".join(body(companion, signature) for signature in command_signatures) macros = ("CMD_GET_CHANNEL", "CMD_SET_CHANNEL", "RESP_CODE_CHANNEL_INFO", "ERR_CODE_UNSUPPORTED_CMD", "ERR_CODE_NOT_FOUND", "ERR_CODE_FILE_IO_ERROR", @@ -1166,6 +1168,7 @@ int main() { "MyMesh::Frame& MyMesh::heldDMFrameAt(", "void MyMesh::removeHeldOneKeyDM(", )) + "\n#endif\n" + methods += "\n" + body(companion, "void MyMesh::handleSetChannelFrame(") branches = " else ".join(body(companion, signature) for signature in ( "if (cmd_frame[0] == CMD_GET_CHANNEL && len >= 2)", "if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 32)", diff --git a/test/test_companion_channel_publication.py b/test/test_companion_channel_publication.py index 993be28cc..1c60dc04d 100644 --- a/test/test_companion_channel_publication.py +++ b/test/test_companion_channel_publication.py @@ -88,6 +88,7 @@ def publication_fixture(work, missing_latch, skip_noop_save=False): methods = recovery + "\n" + route methods += "\n" + method(store, "void DataStore::loadChannels(") methods += "\n" + save + "\n" + method(store, "bool DataStore::hasIncompleteContactLoad(") + methods += "\n" + method(mesh, "void MyMesh::handleSetChannelFrame(") binary_start = mesh.index(" } else if (cmd_frame[0] == CMD_SET_CHANNEL && len >= 2 + 32 + 16) {") binary_end = mesh.index(" } else if (cmd_frame[0] == CMD_SIGN_START)", binary_start) methods += ("\nvoid MyMesh::applyChannelFrame(const uint8_t* cmd_frame, size_t len) {\n" @@ -129,6 +130,7 @@ struct StrHelper { void writeOKFrame() { binary_ack = true; } void seedEmergencyChannelNotice(uint8_t, const ChannelDetails&) {} bool resolveChannelAlias(ChannelDetails&) const { return true; } + void handleSetChannelFrame(const uint8_t*); void applyChannelFrame(const uint8_t*, size_t); """, 1) source = source.replace("@RECOVERY_METHOD@", method(header, " bool contactRecoveryPending() const")) diff --git a/test/test_companion_fleet_control.py b/test/test_companion_fleet_control.py index c41dd5f64..be9748435 100644 --- a/test/test_companion_fleet_control.py +++ b/test/test_companion_fleet_control.py @@ -29,6 +29,7 @@ HARNESS = r''' #include #include #include "regulator_cli_stub.h" +#include "usb_mode_console.h" #include #include #include @@ -364,6 +365,10 @@ static void compactBroadcast(MyMesh& node, const char* command) { int main(int argc, char** argv) { assert(argc == 2); + MyMesh usb_probe; + char usb_reply[160] = {}; + assert(usb_probe.handleCommand("AB|get usb.mode", 0, usb_reply)); + assert(std::string(usb_reply) == "AB|usb.mode unknown" && usb_probe.queued == 0); const std::string scenario = argv[1]; MyMesh node; char reply[160] = {}; if (scenario == "local_boundary") { @@ -1513,6 +1518,7 @@ class CompanionFleetControlTests(unittest.TestCase): #include #include #include "regulator_cli_stub.h" +#include "usb_mode_console.h" static_assert(MESH_ENABLE_FLEET_CONTROL == 0, "compact profile must disable fleet"); // No channels, clock, signing identity, codec definitions or sendGroupData. class MyMesh { diff --git a/test/test_companion_meshtext.py b/test/test_companion_meshtext.py index f07311aa2..605083754 100644 --- a/test/test_companion_meshtext.py +++ b/test/test_companion_meshtext.py @@ -60,7 +60,7 @@ class CompanionMeshtextTest(unittest.TestCase): "static bool isEmergencyNoticeChannelName(", "void MyMesh::seedEmergencyChannelNotice(", "void MyMesh::servicePendingChannelNotices(", "bool MyMesh::queueChannelText(", - "bool MyMesh::saveAdvertName(") + "bool MyMesh::saveAdvertName(", "void MyMesh::handleSetChannelFrame(") methods = "\n".join(body(source, signature) for signature in signatures) utils_source = (ROOT / "src/Utils.cpp").read_text(encoding="utf-8") # CLI keys use the production strict hexadecimal parser as well. diff --git a/test/test_companion_primary_radio_persistence.py b/test/test_companion_primary_radio_persistence.py index c431e77cd..7746c6b38 100644 --- a/test/test_companion_primary_radio_persistence.py +++ b/test/test_companion_primary_radio_persistence.py @@ -22,6 +22,7 @@ HARNESS = r''' #include #include "companion_fleet_dispatch.h" #include "battery_calibration_cli_stub.h" +#include "usb_mode_console.h" #include #include #define constrain(value,low,high) ((value)<(low)?(low):((value)>(high)?(high):(value))) diff --git a/test/test_companion_reader.py b/test/test_companion_reader.py index 4373d2f9c..0fb1eeeec 100644 --- a/test/test_companion_reader.py +++ b/test/test_companion_reader.py @@ -392,6 +392,7 @@ int main() { #include #include #include "battery_calibration_cli_stub.h" +#include "usb_mode_console.h" struct BatteryBoard {} board; struct Radio { bool active = false; }; namespace mesh { diff --git a/test/test_companion_rxps_getters.py b/test/test_companion_rxps_getters.py index 81bcc95cd..9607b19b3 100644 --- a/test/test_companion_rxps_getters.py +++ b/test/test_companion_rxps_getters.py @@ -282,6 +282,7 @@ int main() { built = subprocess.run([ compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", *sanitizers, "-DCOMPANION_FEATURE_TEMP_RADIO=0", "-I", str(ROOT / "src"), + "-I", str(ROOT / "test/fixtures"), "-isystem", str(ROOT / "test/mocks"), str(cpp), str(ROOT / "src/helpers/radiolib/RXPowerSaving.cpp"), "-o", str(binary), ], capture_output=True, text=True, timeout=60) diff --git a/test/test_stm32_mini_repeater_profile.py b/test/test_stm32_mini_repeater_profile.py index a691d7c56..91ca5ef6f 100644 --- a/test/test_stm32_mini_repeater_profile.py +++ b/test/test_stm32_mini_repeater_profile.py @@ -39,6 +39,15 @@ def read_config(path): class FakeEnvironment(dict): + def AddBuildMiddleware(self, callback, pattern): + self.middleware = getattr(self, "middleware", []) + [(callback, pattern)] + + def subst(self, value): + return value.replace("$PROJECT_DIR", str(ROOT)) + + def Object(self, node, **flags): + return node, flags + def Append(self, **values): for name, items in values.items(): self.setdefault(name, []).extend(items) @@ -126,6 +135,29 @@ class MiniRepeaterProfileTest(unittest.TestCase): expected = ["keep-existing-flag", *(LINK_SIZE_FLAGS if target == TARGET else ())] self.assertEqual(environment["LINKFLAGS"], expected) self.assertEqual(set(environment), {"PIOENV", "LINKFLAGS"}) + self.assertEqual(len(getattr(environment, "middleware", [])), + int(target == TARGET)) + + def test_shared_reply_copy_policy_changes_only_the_exact_mini_cli_object(self): + class Node: + def __init__(self, path): self.path = path + def srcnode(self): return self + def get_abspath(self): return str(self.path) + + environment = FakeEnvironment(PIOENV=TARGET, CCFLAGS=["-Oz", "-flto"]) + runpy.run_path(str(ROOT / "scripts/stm32_mini_size.py"), init_globals={ + "env": environment, "Import": lambda name: self.assertEqual(name, "env")}) + callback, pattern = environment.middleware[0] + self.assertEqual(pattern, "*CommonCLI.cpp") + cli = Node(ROOT / "src/helpers/CommonCLI.cpp") + same, flags = callback(environment, cli) + self.assertIs(same, cli) + self.assertEqual(flags["CCFLAGS"], ["-Oz", "-flto", "-fno-builtin-strcpy"]) + self.assertEqual(environment["CCFLAGS"], ["-Oz", "-flto"]) + for path in ("test/fixtures/CommonCLI.cpp", "src/helpers/RadioProfileCLI.cpp", + "src/helpers/UsbLogging.cpp"): + other = Node(ROOT / path) + self.assertIs(callback(environment, other), other) def test_capability_contract_declares_only_exact_mini_repeater_omissions(self): command = r'''