diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index a3bca441..899fac53 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -444,6 +444,13 @@ jobs: python3 -B test/test_nrf52_usb_power.py python3 -B test/test_shared_mota_queue.py + - name: Verify opt-in RAK4631 combined Ethernet and OTA modes + run: | + python3 -B test/test_rak_combined_build_config.py -v + python3 -B test/test_rak_combined_ota.py -v + python3 -B test/test_rak_ethernet_transmit.py -v + python3 -B test/test_rak4631_ethernet_runtime.py -v + - name: Verify nRF52 infrastructure trims and unchanged Companion scope run: | python3 -B test/test_nrf52_flash_trim.py -v diff --git a/docs/research/firmware_memory_budget.md b/docs/research/firmware_memory_budget.md index 7d5e1721..7c4195bb 100644 --- a/docs/research/firmware_memory_budget.md +++ b/docs/research/firmware_memory_budget.md @@ -45,6 +45,64 @@ Headless and OLED devices use their own smaller totals. The JSON lists each component and checks the largest available region against the largest planned single allocation. +The experimental RAK4631 combined Ethernet/UART2/adaptive-storage config adds +an explicit 2,560-byte allowance for the UART bridge and its TX semaphore. +Its Ethernet worker reserves 1,024 stack words (4,096 bytes) plus 256 bytes for +task metadata and allocator overhead. Its bounded TCP transmit queue allocates +512 bytes only while Ethernet is enabled; another 64 bytes cover allocator +overhead. The DHCP object, socket state, queue indices and SPI objects are +static and already reduce the linked heap capacity. + +Ethernet and external OTA share an exclusive ownership contract. Enabling +Ethernet rejects a live or pinned transfer, releases idle external OTA buffers, +and blocks encoder, self-source, manual-staging and folder-source allocation +while Ethernet owns SPI. Disabling Ethernet parks the worker after socket/SPI +cleanup; the main loop deletes that parked task synchronously and frees its +TX queue before releasing ownership. The worker stack therefore cannot remain queued for idle-task +reclamation while OTA allocates again. The budget counts the larger of the +4,928-byte Ethernet worker/queue or 5,168-byte external OTA workspaces, with the UART +allowance alongside either mode. It keeps the 8 KiB loop stack, 63 flood rules, +and reserved 64 KiB OTA handoff arena. + +This combined config uses a source-bounded 2 KiB SSD1306 allowance. Its named +128x64 geometry needs one 1,024-byte framebuffer, reused on later starts; +the driver itself is embedded in a linked global, measured at 156 bytes in +the ARM test ELF. Including that already-counted object again plus 32 bytes +for allocation overhead still leaves 836 bytes within the allowance. +A compile-time assertion +covers framebuffer, complete driver object and allocator overhead within that +bound. This corrects a padded allocation estimate; it does not free actual RAM. +Other images retain their existing display budgets. These are local +test configs, not a qualification of a published or physically tested image. + +Generate matched Full/Reduced baseline and combined configs outside the +repository without starting PlatformIO: + +```sh +python3 scripts/generate_rak4631_combined_test.py +``` + +Run its printed commands one at a time and preserve each ELF and memory report +before the next build reuses the environment directory. + +The combined prototype starts with Ethernet off after every restart. Use the +USB CLI commands `eth on`, `eth.status`, and `eth off`; `get eth` reports the +requested mode. UART2 remains independently controlled by +`set bridge.uart 2` and `set rs232.enabled on|off`. Full retains every sensor +driver; absent sensors do not require a different image. + +With Ethernet on, OTA uses eligible internal-flash deltas. External full-image +staging, self-serving, manual serving buffers and OTA folders are unavailable +until Ethernet is off. Active or staged OTA work prevents a mode change. +RAK15001 and Ethernet share chip-select 26 and cannot be used together; the +prototype refuses that combination. External storage also needs a matching +bootloader that permits internal OTA while Ethernet is enabled. + +TCP sends and receives use bounded register operations; a stalled peer cannot +hold the mesh loop waiting for an acknowledgement. DHCP and listener management +run in the Ethernet worker. This is synthetic coverage and a build experiment, +not a physical Ethernet/PoE soak qualification or a replacement release image. + The [small-screen message layout](../test-results/v4_pixel5_font_trial.md) retains complete 160-byte messages. Its expanded preview records add 2,816 bytes to the startup allowance and increase the contiguous history allocation budget. diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index e0252436..4cf0fbce 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -73,6 +73,30 @@ SimpleMeshTables tables; MyMesh the_mesh(board, radio_driver, *new ArduinoMillis(), fast_rng, rtc_clock, tables); #include "../InfrastructureWireless.h" +#if defined(RAK4631_COMBINED_ETHERNET) +// Storage selection and OTA policy belong to the mesh loop. The Ethernet +// worker only handles its own controller and asks these hooks to hand the +// shared WisBlock pins over before/after it runs. +static bool prepareCombinedEthernet(void*, char* error, size_t capacity) { + auto* context = mesh::ota::ota_context_if_active(); + if (!context) { + snprintf(error, capacity, "Error: OTA storage is not ready"); + return false; + } + return context->prepareRakEthernet(error, capacity); +} + +static bool canReleaseCombinedEthernet(void*) { + auto* context = mesh::ota::ota_context_if_active(); + return context && context->canReleaseRakEthernet(); +} + +static bool releaseCombinedEthernet(void*) { + auto* context = mesh::ota::ota_context_if_active(); + return context && context->releaseRakEthernet(); +} +#endif + void halt() { while (1) ; } @@ -340,6 +364,13 @@ void setup() { #endif #ifdef ETHERNET_ENABLED +#if defined(RAK4631_COMBINED_ETHERNET) + EthernetCliHooks hooks; + hooks.prepare = prepareCombinedEthernet; + hooks.can_release = canReleaseCombinedEthernet; + hooks.released = releaseCombinedEthernet; + ethernet_configure(hooks); // First prototype defaults OFF on every restart. +#endif ethernet_start_task(); #endif @@ -481,7 +512,13 @@ static void __attribute__((noinline)) serviceCommandInterfaces() { #ifdef ETHERNET_ENABLED ethernet_loop_maintain(); +#if defined(RAK4631_COMBINED_ETHERNET) + // The facade never accesses SPI while startup, renewal, or shutdown owns + // the controller. Keep pending output during a temporary renewal pause. + if (ethernet_take_session_reset() || !ethernet_get_enabled()) { +#else if (ethernet_take_session_reset() || !ethernet_client.connected()) { +#endif the_mesh.cancelLocalOutput(ethernet_client); ethernet_command[0] = 0; } diff --git a/scripts/check_firmware_ram.py b/scripts/check_firmware_ram.py index e03c8397..ea51f140 100644 --- a/scripts/check_firmware_ram.py +++ b/scripts/check_firmware_ram.py @@ -62,11 +62,28 @@ def requirements(platform, defines, target): else: raise ValueError(f"add a runtime allocation budget for display {display!r}") parts = {} + combined_rak_ethernet = "RAK4631_COMBINED_ETHERNET" in defines + if combined_rak_ethernet and (platform != "NRF52_PLATFORM" + or not {"RAK_4631", "ETHERNET_ENABLED", "OTA_RAK_AUTO_STORE"}.issubset(defines)): + raise ValueError("combined RAK Ethernet budget requires RAK4631 adaptive nRF52 storage") + if combined_rak_ethernet and display == "SSD1306Display": + # The fixed 128x64 driver allocates one 1024-byte framebuffer and + # reuses it after power cycling. Its linked source assertion binds + # framebuffer + complete driver object + allocator overhead to 2 KiB. + # The actual linked ARM driver is 156 bytes; it is already static, + # so including it here again retains a conservative allocation bound. + display_heap = 2048 if platform == "NRF52_PLATFORM": parts["loop_and_callback_stacks"] = integer(defines, "MESH_NRF52_LOOP_STACK_WORDS", 2048) * 4 + 3072 parts["core_usb_filesystems_sensors"] = 12288 if "BLE_PIN_CODE" in defines: parts["bluetooth_worker_stacks"] = 5920 + if combined_rak_ethernet and "WITH_RS232_BRIDGE" in defines: + # The bridge owns a packet/ACK duplicate table as well as its RX + # frame. Its source assertion bounds the object to 2304 bytes; + # retain room for allocation metadata and the UART TX semaphore. + # UART RX/TX arrays are embedded in the already-linked globals. + parts["uart_bridge_and_driver"] = 2560 elif platform == "ESP32_PLATFORM": parts["core_tasks_usb_filesystems"] = 24576 wifi = 49152 if "WIFI_SSID" in defines or "WIFI_OTA_SEEDER" in defines else 0 @@ -133,6 +150,29 @@ def requirements(platform, defines, target): # Unlike ESP32's existing source scratch reserve these allocations # were formerly covered only by the general transient margin. parts["ota_self_source_scratch"] = 2 * 2048 + 2 * 16 + if combined_rak_ethernet: + words = integer(defines, "RAK4631_ETHERNET_TASK_STACK_WORDS", 1024) + if words < 1024: + raise ValueError("combined RAK Ethernet task requires at least 1024 stack words") + tx_capacity = integer(defines, "ETHERNET_CLI_TX_BUFFER_BYTES", 512) + if not 512 <= tx_capacity <= 2048: + raise ValueError("combined RAK Ethernet TX queue requires 512..2048 bytes") + # Ethernet owns the shared bus only after rejecting live/pinned OTA + # sessions and freeing the external self-source buffers. While it owns + # that bus, OTA encoder/self-source entry points refuse allocation. + # On shutdown the main loop deletes the parked worker synchronously + # and frees its TX queue before releasing ownership, so its stack + # cannot await idle cleanup + # while external OTA workspaces are allocated again. + # UART can remain enabled in either mode, so its budget stays above. + # The library's function-static DHCP object and SPI/socket globals are + # already present in the linked image. The bounded TCP TX queue is + # allocated only while Ethernet is enabled; reserve its capacity plus + # 64 bytes for allocator overhead alongside the worker stack and TCB. + external_ota = sum(parts.pop(name, 0) for name in ( + "ota_encoder_workspace", "ota_self_source_scratch")) + parts["ethernet_or_external_ota_workspaces"] = max(words * 4 + 256 + tx_capacity + 64, + external_ota) if display and display != "NullDisplayDriver": parts["display_pixels_and_driver"] = display_heap screen_budget = integer(defines, "MESH_COMPANION_SCREEN_STARTUP_BYTES", 0) @@ -164,7 +204,8 @@ def requirements(platform, defines, target): if "expanded_message_previews" in parts: largest = max(largest, 8192 + parts["expanded_message_previews"]) for name in ("client_table", "flood_filter_table", "room_flood_rule_table", - "ota_context", "screen_objects_and_history", "ota_encoder_workspace"): + "ota_context", "screen_objects_and_history", "ota_encoder_workspace", + "ethernet_or_external_ota_workspaces"): largest = max(largest, parts.get(name, 0)) return {"required_heap_bytes": required, "required_contiguous_bytes": largest, "components": parts, "display": display, "full_companion": full} diff --git a/scripts/generate_rak4631_combined_test.py b/scripts/generate_rak4631_combined_test.py new file mode 100644 index 00000000..4a74544e --- /dev/null +++ b/scripts/generate_rak4631_combined_test.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Generate matched local RAK4631 baseline/combined test configs, without building. + +The production environment and release artifacts are left untouched. The four +configs retain the unified target's OTA identity, 8 KiB loop stack, 63 flood rules +and exact board recipe. Full/Reduced sensor flags come from build.sh's normal +helpers. Run the printed PlatformIO commands individually; this checkout permits +only one PlatformIO process at a time. Preserve each build's outputs before the +next command reuses its environment directory. +""" + +from __future__ import annotations + +import argparse +import configparser +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import subprocess + + +ROOT = Path(__file__).resolve().parents[1] +TARGET = "RAK_4631_repeater_unified_lora_ota" +ETHERNET_LIBRARY = "https://github.com/RAKWireless/RAK13800-W5100S/archive/1.0.2.zip" + +PROFILE_HELPERS = r''' +set -euo pipefail +source build.sh +target=RAK_4631_repeater_unified_lora_ota +PIO_ENV_PLATFORM_BY_NAME[$target]=NRF52_PLATFORM +PIO_ENV_QSPI_OTA_BY_NAME[$target]=1 +PIO_ENV_OTA_BY_NAME[$target]=1 +PIO_ENV_SD_OTA_BY_NAME[$target]=0 +NRF52_OTA_SENSOR_PROFILE=$MESH_TRIAL_SENSOR_PROFILE +SKIP_DECLARED_REDUCTIONS=$([ "$NRF52_OTA_SENSOR_PROFILE" = full ] && printf 1 || printf 0) +DISABLE_DEBUG=1 +PLATFORMIO_BUILD_FLAGS='' +PLATFORMIO_BUILD_UNFLAGS='' +disable_debug_flags "$target" +apply_nrf52_size_profile "$target" +apply_lora_ota_no_external_sensors_profile "$target" +apply_radio_overrides +apply_firmware_profile_overrides +export PLATFORMIO_BUILD_FLAGS PLATFORMIO_BUILD_UNFLAGS +python3 -c 'import json,os;print(json.dumps({name:os.environ.get(name, "") for name in ("PLATFORMIO_BUILD_FLAGS","PLATFORMIO_BUILD_UNFLAGS")}))' +''' + + +def profile_flags(sensor: str) -> dict[str, str]: + # No project-context initialization, build worker or PlatformIO invocation. + environment = {**os.environ, "MESH_TRIAL_SENSOR_PROFILE": sensor, + "FIRMWARE_PROFILE_OVERRIDE": "cascade", + "RADIO_FREQ_OVERRIDE": "910.525", "RADIO_BW_OVERRIDE": "62.5", + "RADIO_SF_OVERRIDE": "7", "RADIO_CR_OVERRIDE": "5", + "RADIO_SETTING_TITLE": "USA Cascadia"} + result = subprocess.run(["bash", "-c", PROFILE_HELPERS], cwd=ROOT, + env=environment, capture_output=True, text=True, check=True) + values = json.loads(result.stdout.splitlines()[-1]) + if set(values) != {"PLATFORMIO_BUILD_FLAGS", "PLATFORMIO_BUILD_UNFLAGS"}: + raise ValueError("unexpected build-profile helper output") + return values + + +def generate(output: Path, firmware_version: str) -> dict: + output = output.expanduser().resolve() + if output == ROOT or ROOT in output.parents: + raise ValueError("put experimental configs outside the repository and release output") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+-]*", firmware_version): + raise ValueError("firmware version must contain only letters, digits, dots, underscores, plus or minus") + source = (ROOT / "platformio.ini").read_text() + extra_configs = " variants/*/platformio.ini\n platformio.local.ini" + if source.count(extra_configs) != 1: + raise ValueError("main extra_configs changed; refusing a disconnected trial config") + variant = configparser.ConfigParser(interpolation=None, inline_comment_prefixes=(";",)) + variant.read(ROOT / "variants/rak4631/platformio.ini") + raw_flags = variant["env:" + TARGET]["build_flags"] + mota_id = "0x%08x" % int.from_bytes(hashlib.sha256(TARGET.encode()).digest()[:4], "little") + output.mkdir(parents=True, exist_ok=True) + profiles, configs = {}, [] + for sensor in ("full", "reduced"): + flags = profile_flags(sensor) + profiles[sensor] = flags + for combined in (False, True): + name = ("combined" if combined else "baseline") + ("" if sensor == "full" else "-reduced") + common = (f"-DFIRMWARE_VERSION='\"{firmware_version}\"' " + f"-DOTA_VARIANT='\"{TARGET}\"' -DMOTA_TARGET_ID={mota_id}") + build_flags = raw_flags + "\n" + flags["PLATFORMIO_BUILD_FLAGS"] + " " + common + if combined: + build_flags += " -DETHERNET_ENABLED=1 -DRAK4631_COMBINED_ETHERNET=1" + overlay = "[env:" + TARGET + "]\nbuild_flags =\n" + overlay += "\n".join(" " + line.strip() for line in build_flags.splitlines() if line.strip()) + "\n" + overlay += "build_unflags = " + flags["PLATFORMIO_BUILD_UNFLAGS"].strip() + "\n" + if combined: + overlay += "lib_deps = ${rak4631.lib_deps}\n " + ETHERNET_LIBRARY + "\n" + overlay_path = output / ("overlay-" + name + ".ini") + overlay_path.write_text(overlay) + config = source.replace(extra_configs, + " " + str(ROOT / "variants/*/platformio.ini") + "\n" + " " + str(ROOT / "platformio.local.ini") + "\n" + " " + str(overlay_path)) + config_path = output / ("platformio-" + name + ".ini") + config_path.write_text(config) + configs.append({"profile": name, "config": str(config_path), + "config_sha256": hashlib.sha256(config.encode()).hexdigest(), + "overlay_sha256": hashlib.sha256(overlay.encode()).hexdigest()}) + source_commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + receipt = {"schema_version": 1, "target": TARGET, "mota_target_id": mota_id, + "firmware_version": firmware_version, "full_rule_capacity": 63, + "loop_stack_bytes": 8192, "source_commit": source_commit, + "profiles": profiles, "configs": configs, + "scope": "Local config generation only; builds and hardware acceptance remain required."} + (output / "profile-flags.json").write_text(json.dumps(receipt, indent=2) + "\n") + return receipt + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + default = Path.home() / ".cache" / ("meshcore-rak-combined-" + datetime.now(timezone.utc).strftime("%Y%m%d")) + parser.add_argument("--output", type=Path, default=default) + parser.add_argument("--firmware-version", default="v1.17.1.9-rak-combined-trial") + args = parser.parse_args() + receipt = generate(args.output, args.firmware_version) + for config in receipt["configs"]: + print(" ".join(shlex.quote(value) for value in ( + "pio", "run", "-d", str(ROOT), "-c", config["config"], "-e", TARGET))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/helpers/bridges/RS232Bridge.cpp b/src/helpers/bridges/RS232Bridge.cpp index 83592d93..e86e7d3a 100644 --- a/src/helpers/bridges/RS232Bridge.cpp +++ b/src/helpers/bridges/RS232Bridge.cpp @@ -10,6 +10,11 @@ // allocation even when a combined Full image starts with the UART disabled. static_assert(sizeof(RS232Bridge) <= 4096, "Update the ESP32 UART heap budget"); #endif +#if defined(NRF52_PLATFORM) && defined(RAK4631_COMBINED_ETHERNET) +// The combined RAK RAM policy reserves this object plus allocator metadata +// and the UART driver's TX semaphore. Its RX/TX arrays are linked globals. +static_assert(sizeof(RS232Bridge) <= 2304, "Update the combined RAK UART heap budget"); +#endif RS232Bridge::RS232Bridge(NodePrefs *prefs, Stream &serial, int16_t rx_pin, int16_t tx_pin, mesh::PacketManager *mgr, diff --git a/src/helpers/nrf52/EthernetCLI.h b/src/helpers/nrf52/EthernetCLI.h index 495acd37..5115e599 100644 --- a/src/helpers/nrf52/EthernetCLI.h +++ b/src/helpers/nrf52/EthernetCLI.h @@ -7,6 +7,9 @@ #include #include #include +#if defined(RAK4631_COMBINED_ETHERNET) + #include +#endif // Ethernet startup runs on a FreeRTOS task, and client acceptance runs in the // mesh loop. Never let either path wait on a CDC host which stopped reading. @@ -38,15 +41,37 @@ static SPIClass ETHERNET_SPI_PORT(NRF_SPIM1, PIN_SPI1_MISO, PIN_SPI1_SCK, PIN_SP #define ETHERNET_RETRY_INTERVAL_MS 30000 static EthernetServer ethernet_server(ETHERNET_TCP_PORT); +#if !defined(RAK4631_COMBINED_ETHERNET) static EthernetClient ethernet_client; +#endif +#if defined(RAK4631_COMBINED_ETHERNET) +static std::atomic ethernet_running{false}; +#else static volatile bool ethernet_running = false; +#endif +#if defined(RAK4631_COMBINED_ETHERNET) +static std::atomic ethernet_session_reset{false}; +static bool ethernet_line_discarding = false; // Main-thread framing state. +#else static bool ethernet_session_reset = false; +#endif static bool ethernet_take_session_reset() { +#if defined(RAK4631_COMBINED_ETHERNET) + const bool changed = ethernet_session_reset.exchange(false); + if (changed) ethernet_line_discarding = false; + return changed; +#else const bool changed = ethernet_session_reset; ethernet_session_reset = false; return changed; +#endif } +#if defined(RAK4631_COMBINED_ETHERNET) +static void ethernet_check_client(); +#include "EthernetCliRuntime.h" +#else + // FreeRTOS task: handles hw init, DHCP, and retries in the background static void ethernet_task(void* param) { (void)param; @@ -99,10 +124,48 @@ static void ethernet_task(void* param) { static void ethernet_start_task() { xTaskCreate(ethernet_task, "eth_init", 1024, NULL, 1, NULL); } +#endif // Format ethernet status into reply buffer. Returns true if command was handled. static bool ethernet_handle_command(const char* command, char* reply) { +#if defined(RAK4631_COMBINED_ETHERNET) + if (strcmp(command, "eth on") == 0 || strcmp(command, "set eth on") == 0) { + (void)ethernet_request_start(reply, true); + return true; + } + if (strcmp(command, "eth off") == 0 || strcmp(command, "set eth off") == 0) { + (void)ethernet_request_stop(reply); + return true; + } + if (strcmp(command, "get eth") == 0) { + strcpy(reply, ethernet_enabled.load() ? "> on" : "> off"); + return true; + } + if (strncmp(command, "eth ", 4) == 0 || strncmp(command, "set eth ", 8) == 0) { + strcpy(reply, "Error: Ethernet mode must be on or off"); + return true; + } +#endif if (strcmp(command, "eth.status") == 0) { +#if defined(RAK4631_COMBINED_ETHERNET) + const EthernetCliState state = ethernet_state.load(); + if (state != EthernetCliState::Online) { + const char* status = state == EthernetCliState::Off ? "off" : + state == EthernetCliState::Starting ? "starting" : + state == EthernetCliState::Retrying ? "waiting for DHCP" : + state == EthernetCliState::Maintaining ? "renewing DHCP" : + state == EthernetCliState::Stopping ? "stopping" : + state == EthernetCliState::NoHardware ? "hardware not found" : + state == EthernetCliState::Faulted ? "controller stalled; turn Ethernet off/on" : "start failed"; + snprintf(reply, 160, "ETH: %s", status); + return true; + } + const uint32_t ip = ethernet_cached_ip.load(); + snprintf(reply, 160, "ETH: %u.%u.%u.%u:%d", (unsigned)(ip & 255), + (unsigned)((ip >> 8) & 255), (unsigned)((ip >> 16) & 255), + (unsigned)((ip >> 24) & 255), ETHERNET_TCP_PORT); + return true; +#endif if (!ethernet_running) { strcpy(reply, "ETH: not connected"); } else { @@ -119,6 +182,36 @@ static bool ethernet_handle_command(const char* command, char* reply) { // available() also returns existing connected sockets that have data, which // would force us to disambiguate every inbound packet from a real new client. static void ethernet_check_client() { +#if defined(RAK4631_COMBINED_ETHERNET) + // Combined accept/relisten can call vendor controller commands; run only + // in the background worker while the mesh-facing Stream is gated. + auto newClient = ethernet_server.accept(); + if (newClient) { + ethernet_session_generation.fetch_add(1); + if (ethernet_raw_client && ethernet_raw_client.getSocketNumber() != newClient.getSocketNumber()) { + (void)mesh::nrf52::EthernetCliTransmit::close(ethernet_raw_client.getSocketNumber()); + ethernet_require_command_check(); + } + ethernet_queue_clear(); + ethernet_transmit.reset(); + ethernet_raw_client = newClient; + ethernet_client_attached.store(true); + ethernet_session_reset = true; + static const char banner[] = ETHERNET_CLI_BANNER "\r\n"; + (void)ethernet_queue_write(reinterpret_cast(banner), sizeof(banner) - 1); + } + if (ethernet_raw_client && + !mesh::nrf52::EthernetCliTransmit::connected(ethernet_raw_client.getSocketNumber())) { + ethernet_session_generation.fetch_add(1); + (void)mesh::nrf52::EthernetCliTransmit::close(ethernet_raw_client.getSocketNumber()); + ethernet_require_command_check(); + ethernet_raw_client = EthernetClient(); + ethernet_client_attached.store(false); + ethernet_queue_clear(); + ethernet_transmit.reset(); + ethernet_session_reset = true; + } +#else auto newClient = ethernet_server.accept(); if (newClient) { if (ethernet_client) ethernet_client.stop(); @@ -128,20 +221,102 @@ static void ethernet_check_client() { ETHERNET_CLI_LOG("Client connected from %u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); ethernet_client.println(ETHERNET_CLI_BANNER); } +#endif } // Call from loop() to maintain DHCP and check for new clients static void ethernet_loop_maintain() { +#if defined(RAK4631_COMBINED_ETHERNET) + if (ethernet_state.load() == EthernetCliState::Stopping) { + if (ethernet_worker_active.load() && !ethernet_worker_stopped.load()) { + EthernetSpiAccess expected = EthernetSpiAccess::Idle; + if (ethernet_spi_access.compare_exchange_strong(expected, EthernetSpiAccess::Worker)) { + ethernet_running.store(false); + } + if (ethernet_spi_access.load() == EthernetSpiAccess::Worker) ethernet_notify_worker(); + return; + } + if (ethernet_complete_release()) { + ethernet_state.store(EthernetCliState::Off); + } + return; + } + if (!ethernet_running.load() && ethernet_bus_reserved && + (!ethernet_worker_active.load() || ethernet_worker_stopped.load())) { + (void)ethernet_complete_release(); + return; + } + if (ethernet_running.load()) { + if (static_cast(millis() - ethernet_last_maintain.load()) >= 1000) { + ethernet_request_maintenance(); + } + } +#else if (ethernet_running) { ethernet_check_client(); Ethernet.maintain(); } +#endif } // Read a line from the Ethernet client into the command buffer. // Returns true when a complete line is ready to process (command is null-terminated). // The caller should process the command and then reset ethernet_command[0] = 0. static bool ethernet_read_line(char* ethernet_command, size_t buf_size) { +#if defined(RAK4631_COMBINED_ETHERNET) + if (buf_size < 2) return false; + EthernetClientAccess access; + if (!access || !ethernet_client_attached.load()) return false; + const uint32_t generation = ethernet_session_generation.load(); + if (ethernet_session_reset.load() || + ((ethernet_command[0] || ethernet_line_discarding) && ethernet_line_generation != generation)) { + ethernet_command[0] = 0; + ethernet_line_discarding = false; + return false; + } + ethernet_line_generation = generation; + const uint8_t socket = ethernet_raw_client.getSocketNumber(); + if (!mesh::nrf52::EthernetCliTransmit::connected(socket)) return false; + // Reserve one complete synchronous CLI reply before dispatching a command; + // Print's component writes cannot otherwise retain a partially queued reply. + if (ethernet_queue_capacity() < 170) return false; + size_t length = strlen(ethernet_command); + // One gate covers this bounded turn, so a worker cannot replace the peer + // between bytes or between the terminal byte and reply-owner publication. + for (unsigned consumed = 0; consumed < 128 && + mesh::nrf52::EthernetCliTransmit::available(socket); ++consumed) { + const int next = mesh::nrf52::EthernetCliTransmit::read(socket); + if (!mesh::nrf52::EthernetCliTransmit::commandReady(socket)) ethernet_require_command_check(); + if (next < 0) break; + const char c = static_cast(next); + if (c == '\r' || c == '\n') { + if (ethernet_line_discarding) { + ethernet_line_discarding = false; + ethernet_command[0] = 0; + static const char error[] = "Error: invalid or oversized command; discarded\r\n"; + (void)ethernet_queue_write(reinterpret_cast(error), sizeof(error) - 1); + ethernet_session_reset.store(true); + return false; + } + if (length == 0) continue; + ethernet_command[length] = 0; + ethernet_reply_generation = generation; + static const uint8_t newline[] = {'\r', '\n'}; + (void)ethernet_queue_write(newline, sizeof(newline)); + return true; + } + if (ethernet_line_discarding) continue; + if (c == '\0' || length == buf_size - 1) { + ethernet_line_discarding = true; + ethernet_command[0] = 0; + length = 0; + continue; + } + ethernet_command[length++] = c; + ethernet_command[length] = 0; + } + return false; +#else if (!ethernet_running || !ethernet_client || !ethernet_client.connected()) return false; int elen = strlen(ethernet_command); @@ -162,12 +337,24 @@ static bool ethernet_read_line(char* ethernet_command, size_t buf_size) { return true; } return false; +#endif } // Send a reply to the Ethernet client static void ethernet_send_reply(const char* reply) { if (reply[0]) { +#if defined(RAK4631_COMBINED_ETHERNET) + // One RAM-only enqueue keeps a synchronous command's complete reply even + // if the worker begins renewal between command dispatch and this call. + char frame[170]; + const int length = snprintf(frame, sizeof(frame), " -> %s\r\n", reply); + if (length > 0 && static_cast(length) < sizeof(frame)) { + (void)ethernet_queue_write(reinterpret_cast(frame), static_cast(length), + true, ethernet_reply_generation); + } +#else ethernet_client.print(" -> "); ethernet_client.println(reply); +#endif } } diff --git a/src/helpers/nrf52/EthernetCliRuntime.h b/src/helpers/nrf52/EthernetCliRuntime.h new file mode 100644 index 00000000..d9dbf6de --- /dev/null +++ b/src/helpers/nrf52/EthernetCliRuntime.h @@ -0,0 +1,544 @@ +#pragma once + +#include +#include "EthernetCliTransmit.h" + +// Included by EthernetCLI.h only for the opt-in combined RAK4631 prototype. +// Legacy Ethernet profiles retain their established implementation. +struct EthernetCliHooks { + bool (*prepare)(void*, char*, size_t) = nullptr; + bool (*can_release)(void*) = nullptr; + bool (*released)(void*) = nullptr; + bool (*save)(void*, bool) = nullptr; + void* context = nullptr; +}; + +enum class EthernetCliState : uint8_t { + Off, Starting, Retrying, Online, Maintaining, Stopping, NoHardware, StartFailed, Faulted +}; +enum class EthernetSpiAccess : uint8_t { Idle, Client, Worker }; +static EthernetCliHooks ethernet_hooks; +static std::atomic ethernet_enabled{false}; +static std::atomic ethernet_worker_active{false}; +static std::atomic ethernet_worker_stopped{false}; +static std::atomic ethernet_state{EthernetCliState::Off}; +static std::atomic ethernet_spi_access{EthernetSpiAccess::Worker}; +static std::atomic ethernet_maintain_requested{false}; +static std::atomic ethernet_cached_ip{0}; +static std::atomic ethernet_last_maintain{0}; +// Only scheduler-critical sections access the handle. OTA context callbacks +// and bus reservation changes run exclusively on the main thread. +static TaskHandle_t ethernet_task_handle = nullptr; +static bool ethernet_bus_reserved = false; +static bool ethernet_spi_started = false; +static bool ethernet_hardware_found = false; +static EthernetClient ethernet_raw_client; +static std::atomic ethernet_client_attached{false}; +static std::atomic ethernet_session_generation{0}; +static uint32_t ethernet_reply_generation = 0; // Main-thread command owner. +static uint32_t ethernet_line_generation = 0; // Main-thread partial input owner. +static std::atomic ethernet_command_check_requested{false}; +static std::atomic ethernet_command_check_started{0}; +static bool ethernet_controller_faulted = false; +static mesh::nrf52::EthernetCliTransmit ethernet_transmit; + +#ifndef ETHERNET_CLI_TX_BUFFER_BYTES + #define ETHERNET_CLI_TX_BUFFER_BYTES 512 +#endif +static_assert(ETHERNET_CLI_TX_BUFFER_BYTES >= 512 && ETHERNET_CLI_TX_BUFFER_BYTES <= 2048, + "Ethernet queue capacity must match its qualified RAM budget"); +// This queue exists only while Ethernet owns SPI. Free it and synchronously +// reclaim the worker before external OTA allocates its mutually exclusive RAM. +static uint8_t* ethernet_tx_queue = nullptr; +static size_t ethernet_tx_head = 0; +static size_t ethernet_tx_count = 0; + +#ifndef RAK4631_ETHERNET_TASK_STACK_WORDS + #define RAK4631_ETHERNET_TASK_STACK_WORDS 1024 +#endif +static_assert(RAK4631_ETHERNET_TASK_STACK_WORDS >= 1024, + "Ethernet worker requires its qualified stack budget"); +static_assert(sizeof(StaticTask_t) + 32 <= 256, + "Ethernet task metadata exceeds its qualified RAM allowance"); + +static bool ethernet_is_running() { return ethernet_running.load(); } +static bool ethernet_get_enabled() { return ethernet_enabled.load(); } + +static void ethernet_notify_worker() { + taskENTER_CRITICAL(); + if (ethernet_task_handle != nullptr) xTaskNotifyGive(ethernet_task_handle); + taskEXIT_CRITICAL(); +} + +static void ethernet_queue_clear() { + taskENTER_CRITICAL(); + ethernet_tx_head = 0; + ethernet_tx_count = 0; + taskEXIT_CRITICAL(); +} + +static size_t ethernet_queue_capacity() { + taskENTER_CRITICAL(); + const size_t free = ethernet_tx_queue ? ETHERNET_CLI_TX_BUFFER_BYTES - ethernet_tx_count : 0; + taskEXIT_CRITICAL(); + return free; +} + +static size_t ethernet_queue_write(const uint8_t* data, size_t size, + bool bind_session = false, uint32_t generation = 0) { + taskENTER_CRITICAL(); + if (!ethernet_tx_queue || !ethernet_client_attached.load() || + (bind_session && generation != ethernet_session_generation.load())) { + taskEXIT_CRITICAL(); + return 0; + } + const size_t free = ETHERNET_CLI_TX_BUFFER_BYTES - ethernet_tx_count; + if (size > free) size = free; + const size_t tail = (ethernet_tx_head + ethernet_tx_count) % ETHERNET_CLI_TX_BUFFER_BYTES; + const size_t first = size < ETHERNET_CLI_TX_BUFFER_BYTES - tail ? size : ETHERNET_CLI_TX_BUFFER_BYTES - tail; + memcpy(ethernet_tx_queue + tail, data, first); + memcpy(ethernet_tx_queue, data + first, size - first); + ethernet_tx_count += size; + taskEXIT_CRITICAL(); + if (size) ethernet_notify_worker(); + return size; +} + +static void ethernet_require_command_check() { + ethernet_running.store(false); + ethernet_command_check_started.store(millis()); + ethernet_command_check_requested.store(true); + ethernet_notify_worker(); +} + +class EthernetClientAccess { + bool acquired = false; +public: + EthernetClientAccess() { + if (!ethernet_running.load()) return; + EthernetSpiAccess expected = EthernetSpiAccess::Idle; + acquired = ethernet_spi_access.compare_exchange_strong(expected, EthernetSpiAccess::Client); + if (acquired && !ethernet_running.load()) { + ethernet_spi_access.store(EthernetSpiAccess::Idle); + acquired = false; + } + } + ~EthernetClientAccess() { + if (!acquired) return; + ethernet_spi_access.store(EthernetSpiAccess::Idle); + } + explicit operator bool() const { return acquired; } +}; + +// MyMesh retains this Stream for asynchronous replies. It must never expose +// the raw client while DHCP initialization/renewal owns the SPI bus. Busy +// methods yield immediately so radio, USB and UART service can continue. +class EthernetCliClient : public Stream { +public: + int available() override { + EthernetClientAccess access; + if (!access || !ethernet_client_attached.load()) return 0; + const uint8_t socket = ethernet_raw_client.getSocketNumber(); + const int available = mesh::nrf52::EthernetCliTransmit::available(socket); + if (!mesh::nrf52::EthernetCliTransmit::commandReady(socket)) ethernet_require_command_check(); + return available; + } + int read() override { + EthernetClientAccess access; + if (!access || !ethernet_client_attached.load()) return -1; + const uint8_t socket = ethernet_raw_client.getSocketNumber(); + const int value = mesh::nrf52::EthernetCliTransmit::read(socket); + if (!mesh::nrf52::EthernetCliTransmit::commandReady(socket)) ethernet_require_command_check(); + return value; + } + int peek() override { + EthernetClientAccess access; + if (!access || !ethernet_client_attached.load()) return -1; + const uint8_t socket = ethernet_raw_client.getSocketNumber(); + const int value = mesh::nrf52::EthernetCliTransmit::peek(socket); + if (!mesh::nrf52::EthernetCliTransmit::commandReady(socket)) ethernet_require_command_check(); + return value; + } + int availableForWrite() override { + EthernetClientAccess access; + return access && ethernet_client_attached.load() ? static_cast(ethernet_queue_capacity()) : 0; + } + size_t write(uint8_t value) override { return write(&value, 1); } + size_t write(const uint8_t* data, size_t size) override { + EthernetClientAccess access; + if (!access || ethernet_session_reset.load()) return 0; + return ethernet_queue_write(data, size); + } + void flush() override {} // Never wait for a remote TCP host. + bool connected() { + EthernetClientAccess access; + return access && ethernet_client_attached.load() && + mesh::nrf52::EthernetCliTransmit::connected(ethernet_raw_client.getSocketNumber()); + } + explicit operator bool() { return connected(); } + void stop() { + EthernetClientAccess access; + if (!access) return; + if (ethernet_raw_client) { + (void)mesh::nrf52::EthernetCliTransmit::close(ethernet_raw_client.getSocketNumber()); + ethernet_require_command_check(); + } + ethernet_raw_client = EthernetClient(); + ethernet_client_attached.store(false); + ethernet_session_generation.fetch_add(1); + ethernet_queue_clear(); + ethernet_transmit.reset(); + ethernet_line_discarding = false; + ethernet_session_reset.store(true); + } + using Print::write; +}; +static EthernetCliClient ethernet_client; + +static bool ethernet_configure(const EthernetCliHooks& hooks, bool saved_enabled = false) { + if (ethernet_bus_reserved || ethernet_worker_active.load()) return false; + ethernet_hooks = hooks; + ethernet_enabled.store(saved_enabled); + ethernet_state.store(EthernetCliState::Off); + return true; +} + +static void ethernet_stop_hardware() { + // The W5100S has four sockets; there is no EthernetServer::end() in the + // library. Stop listener, accepted-client and DHCP sockets before SPI.end. + // Never toggle the PHY reset or shared WisBlock supply during shutdown. + if (ethernet_spi_started && ethernet_hardware_found) { + for (uint8_t index = 0; index < 4; ++index) { + (void)mesh::nrf52::EthernetCliTransmit::close(index); + } + } + ethernet_raw_client = EthernetClient(); + ethernet_client_attached.store(false); + ethernet_session_generation.fetch_add(1); + ethernet_running.store(false); + if (ethernet_spi_started) { + ETHERNET_SPI_PORT.end(); + digitalWrite(PIN_ETHERNET_SS, HIGH); + ethernet_spi_started = false; + } + ethernet_hardware_found = false; + ethernet_transmit.reset(); + ethernet_queue_clear(); + ethernet_command_check_requested.store(false); +} + +static bool ethernet_complete_release() { + // Main-thread only. A stopped worker parks with its handle intact. Deleting + // that other task reclaims its stack synchronously; self-deletion would + // defer reclamation to the idle task and overlap the external OTA workspace. + if (ethernet_worker_active.load()) { + if (!ethernet_worker_stopped.load()) return false; + taskENTER_CRITICAL(); + TaskHandle_t stopped = ethernet_task_handle; + ethernet_task_handle = nullptr; + taskEXIT_CRITICAL(); + if (stopped != nullptr) vTaskDelete(stopped); + ethernet_worker_active.store(false); + ethernet_worker_stopped.store(false); + } + ethernet_stop_hardware(); + free(ethernet_tx_queue); + ethernet_tx_queue = nullptr; + ethernet_line_discarding = false; + if (ethernet_bus_reserved) { + if (!ethernet_hooks.released(ethernet_hooks.context)) return false; + ethernet_bus_reserved = false; + ethernet_session_reset = true; + } + return true; +} + +static void ethernet_worker_take_access() { + while (ethernet_spi_access.load() != EthernetSpiAccess::Worker) { + EthernetSpiAccess expected = EthernetSpiAccess::Idle; + if (ethernet_spi_access.compare_exchange_strong(expected, EthernetSpiAccess::Worker)) break; + vTaskDelay(pdMS_TO_TICKS(1)); + } + ethernet_running.store(false); +} + +static void ethernet_finish_task(EthernetCliState state) { + // No hardware operation follows this publication. Keep the handle and park + // until the main thread deletes this task before handing SPI back to OTA. + taskENTER_CRITICAL(); + ethernet_state.store(state); + ethernet_running.store(false); + ethernet_worker_stopped.store(true); + taskEXIT_CRITICAL(); + for (;;) vTaskDelay(portMAX_DELAY); +} + +static bool ethernet_publish_online() { + taskENTER_CRITICAL(); + const bool enabled = ethernet_enabled.load(); + if (enabled) { + ethernet_state.store(EthernetCliState::Online); + ethernet_spi_access.store(EthernetSpiAccess::Idle); + ethernet_running.store(true); + } + taskEXIT_CRITICAL(); + return enabled; +} + +static void ethernet_cache_ip() { + const IPAddress ip = Ethernet.localIP(); + ethernet_cached_ip.store(static_cast(ip[0]) | + (static_cast(ip[1]) << 8) | + (static_cast(ip[2]) << 16) | + (static_cast(ip[3]) << 24)); +} + +static bool ethernet_has_tx_work() { + taskENTER_CRITICAL(); + const bool queued = ethernet_tx_count != 0; + taskEXIT_CRITICAL(); + return queued || ethernet_transmit.pending(); +} + +static bool ethernet_controller_commands_idle() { + SPIClass* spi = W5100.getSPI(); + if (!spi) return false; + spi->beginTransaction(SPI_ETHERNET_SETTINGS); + bool idle = true; + for (uint8_t socket = 0; socket < 4; ++socket) { + if (W5100.readSnCR(socket) != 0) idle = false; + } + spi->endTransaction(); + return idle; +} + +static void ethernet_poll_command_check() { + if (!ethernet_command_check_requested.load()) return; + if (ethernet_controller_commands_idle()) { + ethernet_command_check_requested.store(false); + } else if (static_cast(millis() - ethernet_command_check_started.load()) >= 250) { + ethernet_controller_faulted = true; + ethernet_state.store(EthernetCliState::Faulted); + ethernet_session_reset.store(true); + } +} + +// Worker-only, while SPI access is Worker. No library socketSend/execCmdSn +// calls: stalled TCP acknowledgement must never hold the mesh loop. +static void ethernet_pump_tx() { + ethernet_transmit.poll(millis()); + if (ethernet_transmit.failed()) { + if (ethernet_raw_client) { + if (!mesh::nrf52::EthernetCliTransmit::close(ethernet_raw_client.getSocketNumber())) { + ethernet_controller_faulted = true; + ethernet_state.store(EthernetCliState::Faulted); + } else { + ethernet_require_command_check(); + } + } + ethernet_raw_client = EthernetClient(); + ethernet_client_attached.store(false); + ethernet_session_generation.fetch_add(1); + ethernet_queue_clear(); + ethernet_transmit.reset(); + ethernet_session_reset.store(true); + return; + } + if (!ethernet_raw_client) { ethernet_queue_clear(); return; } + // Main-loop accesses are excluded by the SPI gate while this worker owns it. + taskENTER_CRITICAL(); + const size_t head = ethernet_tx_head; + const size_t count = ethernet_tx_count; + taskEXIT_CRITICAL(); + if (count == 0) return; + const size_t contiguous = count < ETHERNET_CLI_TX_BUFFER_BYTES - head + ? count : ETHERNET_CLI_TX_BUFFER_BYTES - head; + const size_t written = ethernet_transmit.write(ethernet_raw_client.getSocketNumber(), + ethernet_tx_queue + head, contiguous, millis()); + taskENTER_CRITICAL(); + ethernet_tx_head = (ethernet_tx_head + written) % ETHERNET_CLI_TX_BUFFER_BYTES; + ethernet_tx_count -= written; + taskEXIT_CRITICAL(); +} + +static void ethernet_drain_before_stop() { + if (!ethernet_spi_started || !ethernet_hardware_found) return; + const uint32_t started = millis(); + while (ethernet_has_tx_work() && static_cast(millis() - started) < 250) { + ethernet_pump_tx(); + if (!ethernet_has_tx_work()) break; + (void)ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(10)); + } +} + +// Persistent background worker: both initial DHCP and renewal may wait for +// network responses. Main-loop Stream operations yield while it owns SPI. +// OFF interrupts retry waits, then lets an in-progress library call finish; +// never delete another task in the middle of its SPI transaction. +static void ethernet_task(void*) { + if (ethernet_enabled.load()) { + pinMode(PIN_ETHERNET_RESET, OUTPUT); + digitalWrite(PIN_ETHERNET_RESET, HIGH); + ETHERNET_SPI_PORT.begin(); + ethernet_spi_started = true; + Ethernet.init(ETHERNET_SPI_PORT, PIN_ETHERNET_SS); + uint8_t mac[6]; + generateEthernetMac(mac); + while (ethernet_enabled.load()) { + const bool connected = Ethernet.begin(mac, 10000, 2000) != 0; + ethernet_hardware_found = Ethernet.hardwareStatus() != EthernetNoHardware; + if (!ethernet_enabled.load()) break; + if (!ethernet_hardware_found) { + ethernet_stop_hardware(); + ethernet_finish_task(EthernetCliState::NoHardware); + return; + } + if (connected) { + ethernet_server.begin(); + ethernet_cache_ip(); + ethernet_last_maintain.store(millis()); + if (!ethernet_publish_online()) break; + for (;;) { + // An online OFF is notified by the next mesh-loop maintain call, + // allowing a command received over Ethernet to send its reply first. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(20)); + if (!ethernet_enabled.load()) break; + if (ethernet_controller_faulted) continue; + const bool maintain = ethernet_maintain_requested.exchange(false); + ethernet_worker_take_access(); + ethernet_poll_command_check(); + if (ethernet_controller_faulted || ethernet_command_check_requested.load()) { + // Preserve the maintenance request while command checking yields. + if (maintain) ethernet_maintain_requested.store(true); + continue; + } + if (maintain) (void)Ethernet.maintain(); + if (!ethernet_enabled.load()) break; + if (maintain) ethernet_last_maintain.store(millis()); + // Do not enter vendor accept/relisten while SEND/RECV is pending. + if (!ethernet_transmit.pending()) ethernet_check_client(); + ethernet_pump_tx(); + if (ethernet_controller_faulted || ethernet_transmit.pending() || + ethernet_command_check_requested.load()) continue; + ethernet_cache_ip(); + if (!ethernet_publish_online()) break; + } + break; + } + ethernet_state.store(EthernetCliState::Retrying); + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(ETHERNET_RETRY_INTERVAL_MS)); + } + } + ethernet_worker_take_access(); + ethernet_drain_before_stop(); + ethernet_stop_hardware(); + ethernet_finish_task(EthernetCliState::Stopping); +} + +static bool ethernet_request_start(char* reply, bool persist) { + if (ethernet_state.load() == EthernetCliState::Stopping) { + strcpy(reply, "Error: Ethernet is still stopping"); + return false; + } + if (ethernet_worker_stopped.load() && !ethernet_complete_release()) { + strcpy(reply, "Error: Ethernet bus release is still pending"); + return false; + } + if (ethernet_worker_active.load() || ethernet_running.load()) { + strcpy(reply, "OK - Ethernet already on"); + return true; + } + if (ethernet_bus_reserved && !ethernet_complete_release()) { + strcpy(reply, "Error: Ethernet bus release is still pending"); + return false; + } + if (!ethernet_hooks.prepare || !ethernet_hooks.can_release || !ethernet_hooks.released) { + strcpy(reply, "Error: Ethernet bus safety hooks unavailable"); + return false; + } + reply[0] = 0; + if (!ethernet_hooks.prepare(ethernet_hooks.context, reply, 160)) { + if (!reply[0]) strcpy(reply, "Error: Ethernet bus is busy"); + return false; + } + ethernet_bus_reserved = true; + ethernet_tx_queue = static_cast(malloc(ETHERNET_CLI_TX_BUFFER_BYTES)); + if (!ethernet_tx_queue) { + (void)ethernet_complete_release(); + ethernet_state.store(EthernetCliState::StartFailed); + strcpy(reply, "Error: Ethernet queue allocation failed"); + return false; + } + ethernet_queue_clear(); + if (persist && ethernet_hooks.save && !ethernet_hooks.save(ethernet_hooks.context, true)) { + (void)ethernet_complete_release(); + strcpy(reply, "Error: cannot save Ethernet mode"); + return false; + } + ethernet_enabled.store(true); + ethernet_controller_faulted = false; + ethernet_command_check_requested.store(false); + ethernet_maintain_requested.store(false); + ethernet_spi_access.store(EthernetSpiAccess::Worker); + ethernet_state.store(EthernetCliState::Starting); + ethernet_worker_stopped.store(false); + ethernet_worker_active.store(true); + // Ensure a fast worker cannot clear the output handle before creation has + // finished publishing it. Handle notification/clearing use the same guard. + taskENTER_CRITICAL(); + const BaseType_t created = xTaskCreate(ethernet_task, "eth_init", + RAK4631_ETHERNET_TASK_STACK_WORDS, nullptr, 1, ðernet_task_handle); + taskEXIT_CRITICAL(); + if (created != pdPASS) { + ethernet_task_handle = nullptr; + ethernet_enabled.store(false); + ethernet_worker_active.store(false); + (void)ethernet_complete_release(); + ethernet_state.store(EthernetCliState::StartFailed); + strcpy(reply, "Error: Ethernet task allocation failed"); + return false; + } + strcpy(reply, ethernet_hooks.save + ? "OK - Ethernet starting; use eth.status" + : "OK - Ethernet starting until restart; use eth.status"); + return true; +} + +static bool ethernet_request_stop(char* reply) { + if (ethernet_bus_reserved && !ethernet_hooks.can_release(ethernet_hooks.context)) { + strcpy(reply, "Error: Ethernet mode is locked by OTA activity or staged data"); + return false; + } + if (ethernet_hooks.save && !ethernet_hooks.save(ethernet_hooks.context, false)) { + strcpy(reply, "Error: cannot save Ethernet mode"); + return false; + } + taskENTER_CRITICAL(); + ethernet_enabled.store(false); + const bool active = ethernet_worker_active.load() || ethernet_bus_reserved; + ethernet_state.store(active ? EthernetCliState::Stopping : EthernetCliState::Off); + taskEXIT_CRITICAL(); + // Startup/renewal already owns SPI, so wake cancellation immediately. + // Online OFF waits for the next main-loop maintain to preserve its reply. + if (ethernet_spi_access.load() == EthernetSpiAccess::Worker) ethernet_notify_worker(); + strcpy(reply, active + ? (ethernet_hooks.save ? "OK - Ethernet stopping" : "OK - Ethernet stopping until restart") + : (ethernet_hooks.save ? "OK - Ethernet off" : "OK - Ethernet off until restart")); + return true; +} + +static void ethernet_start_task() { + if (!ethernet_enabled.load()) return; + char reply[160]; + (void)ethernet_request_start(reply, false); +} + +static void ethernet_request_maintenance() { + EthernetSpiAccess expected = EthernetSpiAccess::Idle; + if (!ethernet_spi_access.compare_exchange_strong(expected, EthernetSpiAccess::Worker)) return; + ethernet_running.store(false); + ethernet_state.store(EthernetCliState::Maintaining); + ethernet_last_maintain.store(millis()); + ethernet_maintain_requested.store(true); + ethernet_notify_worker(); +} diff --git a/src/helpers/nrf52/EthernetCliTransmit.h b/src/helpers/nrf52/EthernetCliTransmit.h new file mode 100644 index 00000000..d5d45f61 --- /dev/null +++ b/src/helpers/nrf52/EthernetCliTransmit.h @@ -0,0 +1,219 @@ +#pragma once + +#include +#include +#include +#include + +namespace mesh { +namespace nrf52 { + +#ifndef RAK4631_ETHERNET_SEND_TIMEOUT_MS + #define RAK4631_ETHERNET_SEND_TIMEOUT_MS 250u +#endif +static_assert(RAK4631_ETHERNET_SEND_TIMEOUT_MS > 0u && + RAK4631_ETHERNET_SEND_TIMEOUT_MS < 0x80000000u, + "Ethernet send deadline must support wrap-safe elapsed time"); + +// The vendor socketSend()/execCmdSn() wait for ACK/command completion without +// a software deadline. Queue one SEND directly in the controller, then inspect +// its completion on later worker turns. Every method has a finite number of +// register transfers and never owns heap storage or waits for network traffic. +class EthernetCliTransmit { + static constexpr uint8_t NO_SOCKET = 0xFFu; + static constexpr uint8_t SOCKET_COUNT = 4u; // RAK13800's exact W5100S + static constexpr uint8_t FREE_SIZE_READS = 4u; + uint8_t _socket = NO_SOCKET; + bool _pending = false; + bool _failed = false; + bool _waiting_command = false; + uint32_t _started = 0; + + class Transaction { + SPIClass* _spi; + public: + Transaction() : _spi(W5100.getSPI()) { + if (_spi) _spi->beginTransaction(SPI_ETHERNET_SETTINGS); + } + ~Transaction() { if (_spi) _spi->endTransaction(); } + explicit operator bool() const { return _spi != nullptr; } + }; + + static bool usableStatus(uint8_t status) { + return status == SnSR::ESTABLISHED || status == SnSR::CLOSE_WAIT; + } + + static bool socketValid(uint8_t socket) { + return socket < SOCKET_COUNT && W5100.getChip() == 51u; + } + + static bool receiveSize(uint8_t socket, uint16_t& size) { + uint16_t previous = W5100.readSnRX_RSR(socket); + for (uint8_t count = 1u; count < FREE_SIZE_READS; ++count) { + const uint16_t current = W5100.readSnRX_RSR(socket); + if (current == previous) { + size = current; + return current <= W5100.SSIZE; + } + previous = current; + } + return false; + } + + static int receiveByte(uint8_t socket, bool consume) { + if (!socketValid(socket)) return -1; + Transaction transaction; + if (!transaction || !usableStatus(W5100.readSnSR(socket)) || + W5100.readSnCR(socket) != 0u) return -1; + uint16_t size = 0u; + if (!receiveSize(socket, size) || !size) return -1; + const uint16_t pointer = W5100.readSnRX_RD(socket); + uint8_t value = 0u; + if (W5100.read(static_cast(W5100.RBASE(socket) + (pointer & W5100.SMASK)), + &value, 1u) != 1u) return -1; + if (consume) { + W5100.writeSnRX_RD(socket, static_cast(pointer + 1u)); + W5100.writeSnCR(socket, Sock_RECV); + } + return value; + } + + bool expired(uint32_t now) const { + return static_cast(now - _started) >= RAK4631_ETHERNET_SEND_TIMEOUT_MS; + } + + void fail() { + _failed = true; + _pending = false; + _waiting_command = false; + } + +public: + bool pending() const { return _pending || _waiting_command; } + bool failed() const { return _failed; } + + void reset() { + _socket = NO_SOCKET; + _pending = _failed = _waiting_command = false; + _started = 0; + } + + void poll(uint32_t now) { + if (_failed || (!_pending && !_waiting_command)) return; + Transaction transaction; + if (!transaction) { fail(); return; } + if (!usableStatus(W5100.readSnSR(_socket))) { fail(); return; } + const uint8_t interrupt = W5100.readSnIR(_socket); + if (interrupt & SnIR::TIMEOUT) { + W5100.writeSnIR(_socket, SnIR::TIMEOUT | SnIR::SEND_OK); + fail(); + return; + } + const uint8_t command = W5100.readSnCR(_socket); + if (_pending && (interrupt & SnIR::SEND_OK) && command == 0u) { + W5100.writeSnIR(_socket, SnIR::SEND_OK); + _pending = false; + return; + } + if (_waiting_command && command == 0u) { + _waiting_command = false; + return; + } + if (expired(now)) fail(); + } + + size_t write(uint8_t socket, const uint8_t* data, size_t length, uint32_t now) { + if (!data || !length || _failed) return 0u; + if (socket >= SOCKET_COUNT || W5100.getChip() != 51u) { fail(); return 0u; } + if (_socket != NO_SOCKET && _socket != socket) { fail(); return 0u; } + _socket = socket; + poll(now); + if (_failed || _pending || _waiting_command) return 0u; + Transaction transaction; + if (!transaction) { fail(); return 0u; } + if (!usableStatus(W5100.readSnSR(socket))) { fail(); return 0u; } + if (W5100.readSnCR(socket) != 0u) { + _waiting_command = true; + _started = now; + return 0u; + } + if (!W5100.SSIZE || W5100.SMASK != W5100.SSIZE - 1u) { fail(); return 0u; } + + // A 16-bit free-size register can change between byte reads. The vendor + // retries forever for a matching pair; retain backpressure after at most + // four reads and try again on a later worker turn instead. + uint16_t previous = W5100.readSnTX_FSR(socket); + uint16_t available = 0u; + bool stable = false; + for (uint8_t count = 1u; count < FREE_SIZE_READS; ++count) { + const uint16_t current = W5100.readSnTX_FSR(socket); + if (current == previous) { available = current; stable = true; break; } + previous = current; + } + if (!stable || !available) return 0u; + if (available > W5100.SSIZE) { fail(); return 0u; } + if (length > available) length = available; + const uint16_t count = static_cast(length); + const uint16_t pointer = W5100.readSnTX_WR(socket); + const uint16_t offset = pointer & W5100.SMASK; + const uint16_t first = count <= W5100.SSIZE - offset + ? count : static_cast(W5100.SSIZE - offset); + if (W5100.write(static_cast(W5100.SBASE(socket) + offset), data, first) != first) { + fail(); + return 0u; + } + if (first < count && W5100.write(W5100.SBASE(socket), data + first, + static_cast(count - first)) != count - first) { + fail(); + return 0u; + } + W5100.writeSnTX_WR(socket, static_cast(pointer + count)); + W5100.writeSnIR(socket, SnIR::SEND_OK | SnIR::TIMEOUT); // revoke stale completion + W5100.writeSnCR(socket, Sock_SEND); // deliberately do not call execCmdSn() + _started = now; + _pending = true; + return count; + } + + static bool close(uint8_t socket) { + if (socket >= SOCKET_COUNT || W5100.getChip() != 51u) return false; + Transaction transaction; + if (!transaction || W5100.readSnCR(socket) != 0u) return false; + W5100.writeSnCR(socket, Sock_CLOSE); + return true; + } + + static bool commandReady(uint8_t socket) { + if (!socketValid(socket)) return false; + Transaction transaction; + return transaction && W5100.readSnCR(socket) == 0u; + } + + static int available(uint8_t socket) { + if (!socketValid(socket)) return 0; + Transaction transaction; + if (!transaction || !usableStatus(W5100.readSnSR(socket)) || + W5100.readSnCR(socket) != 0u) return 0; + uint16_t size = 0u; + return receiveSize(socket, size) ? size : 0; + } + + static int peek(uint8_t socket) { return receiveByte(socket, false); } + static int read(uint8_t socket) { return receiveByte(socket, true); } + + static bool connected(uint8_t socket) { + if (!socketValid(socket)) return false; + Transaction transaction; + if (!transaction) return false; + const uint8_t status = W5100.readSnSR(socket); + if (status == SnSR::ESTABLISHED) return true; + if (status != SnSR::CLOSE_WAIT) return false; + uint16_t size = 0u; + // An unstable size snapshot is backpressure, not proof of EOF. Retain + // the client until a stable zero proves its final bytes were consumed. + return !receiveSize(socket, size) || size != 0u; + } +}; + +} // namespace nrf52 +} // namespace mesh diff --git a/src/helpers/nrf52/Rak4631SharedSpi.h b/src/helpers/nrf52/Rak4631SharedSpi.h new file mode 100644 index 00000000..bd51ef78 --- /dev/null +++ b/src/helpers/nrf52/Rak4631SharedSpi.h @@ -0,0 +1,30 @@ +#pragma once + +#if defined(RAK4631_COMBINED_ETHERNET) + +#include + +namespace mesh { +namespace ota { + +// The combined RAK4631 image gives the WisBlock clock/data pins to one +// controller at a time. Reserve them before starting Ethernet's DHCP task; +// release them only after that task and every Ethernet socket have stopped. +// NOR discovery and QSPI staging must never touch these pins while reserved. +inline std::atomic& rak4631_ethernet_spi_owner() { + static std::atomic owner{false}; + return owner; +} + +inline bool rak4631_ethernet_owns_spi() { + return rak4631_ethernet_spi_owner().load(std::memory_order_acquire); +} + +inline void rak4631_set_ethernet_spi_owner(bool ethernet) { + rak4631_ethernet_spi_owner().store(ethernet, std::memory_order_release); +} + +} // namespace ota +} // namespace mesh + +#endif diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 4b4e00a6..6864ea41 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -137,6 +137,9 @@ struct OtaContext { // store. A failed allocation is reported by the CLI before any write. uint8_t* serve_buf = nullptr; bool ensureServeBuffer() { +#if defined(RAK4631_COMBINED_ETHERNET) + if (rak4631_ethernet_owns_spi()) return false; +#endif if (!serve_buf) serve_buf = static_cast(malloc(OTA_SERVE_BUF_SIZE)); return serve_buf != nullptr; } @@ -470,6 +473,12 @@ struct OtaContext { bool attach_folder_source(MotaSource* source, FolderLink link, const char* label, char* msg, size_t cap) { if (!msg || cap == 0) return false; +#if defined(RAK4631_COMBINED_ETHERNET) + if (rak4631_ethernet_owns_spi()) { + snprintf(msg, cap, "ERR turn Ethernet off before attaching an OTA folder"); + return false; + } +#endif if (!source || link == FOLDER_LINK_NONE) { strncpy(msg, "ERR invalid folder source", cap); msg[cap - 1] = 0; @@ -564,6 +573,111 @@ struct OtaContext { } } +#if defined(RAK4631_COMBINED_ETHERNET) + // Only the main thread may change ownership. No allocation or SPI access in + // the DHCP worker is allowed until prepareRakEthernet() has finished. + bool canReleaseRakEthernet() const { + const bool own_image = manager.servingPrimaryManifest(serve_self_manifest); + return manager.fetchState() == OtaManager::IDLE && + fetch_store.staged_size() == 0u && !apply_pending && !bootloader_apply_pending && + !manager.pendingServeJobs() && !manager.pendingManifestJobs() && + (!serving || own_image) && manager.servedCount() <= (own_image ? 1u : 0u) && + serve_expected == 0u && !folder_active && !_folder_source && !folder_dest && + !disconnected_folder_dest && !fetch_to_folder; + } + + void refreshRakStorageCapabilities() { + const bool external = !rak4631_ethernet_owns_spi() && fetch_store.usesExternal(); + manager.set_accept_full(external); + manager.set_accept_bootloader( + ota_bootloader_self_update_caps_valid(bootloaderUpdateCaps())); + manager.set_apply_codec(CODEC_DETOOLS_INPLACE); + manager.set_fetch_store(&fetch_store); + manager.set_archive_interest(false); + self_serve_supported = ota_self_serve_supported(external); +#if MESHCORE_OTA_DEVICE_DEFLATE + manager.set_transport_deflate_encoder(self_serve_supported ? ota_transport_deflate : nullptr); +#endif + } + + void releaseRakIdleServingBuffers() { + // The idle preflight admits only our automatic own-image view, never a + // manual or folder source. Revoke it before freeing the caller-owned RAM. + manager.clear_primary(); + serving = false; + free(serve_self_leaves); free(serve_self_proof); + serve_self_leaves = serve_self_proof = nullptr; + releaseServeBuffer(); + serve_expected = 0u; + } + + bool prepareRakEthernet(char* error, size_t cap) { + if (!error || !cap) return false; + error[0] = 0; + if (rak4631_ethernet_owns_spi()) { + snprintf(error, cap, "ERR Ethernet already owns WisBlock SPI"); + return false; + } + if (!canReleaseRakEthernet()) { + snprintf(error, cap, "ERR OTA busy; cancel transfer, clear manual serve and detach folder first"); + return false; + } + const uint8_t detected = OtaStoreQspiNrf52::autoDetect(); + uint32_t rak_id = 0, w25_id = 0; + OtaStoreQspiNrf52::autoProbeIds(rak_id, w25_id); + (void)w25_id; + if (detected == 1u || detected == 3u) { + snprintf(error, cap, "ERR RAK15001 and Ethernet share chip-select 26; remove RAK15001 first"); + return false; + } + if (rak_id != 0u && rak_id != 0xFFFFFFu) { + snprintf(error, cap, "ERR unknown SPI device on Ethernet chip-select 26 (%06lX)", + (unsigned long)rak_id); + return false; + } + const OtaBlCaps& caps = bootloaderAppCaps(); + const bool qspi_bootloader = caps.present && (caps.storage_flags & OTA_BL_STORAGE_QSPI); + const OtaBootloaderIdentity& identity = bootloaderIdentity(); + if (rak_storage_choice(0u, qspi_bootloader, identity.present && identity.crc_ok, + identity.device_name, false, + caps.optional_app_storage == + (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25)) != + RakStorageChoice::Internal) { + snprintf(error, cap, "ERR installed bootloader cannot use internal OTA with Ethernet"); + return false; + } + if (!fetch_store.resetSelection()) { + snprintf(error, cap, "ERR staged OTA data must be cancelled before enabling Ethernet"); + return false; + } + releaseRakIdleServingBuffers(); + rak4631_set_ethernet_spi_owner(true); + refreshRakStorageCapabilities(); + // The policy check above and the adaptive store must agree before the + // caller allocates a worker or lets the Ethernet library touch hardware. + if (!fetch_store.usesInternal()) { + rak4631_set_ethernet_spi_owner(false); + fetch_store.resetSelection(); + refreshRakStorageCapabilities(); + snprintf(error, cap, "ERR internal OTA selection failed; Ethernet remains off"); + return false; + } + return true; + } + + bool releaseRakEthernet() { + if (!rak4631_ethernet_owns_spi()) return true; + // OFF is cooperative. A transfer can start after its first preflight; + // keep the safe internal backend pinned and let the main loop retry after + // cancellation/completion. The Ethernet worker and SPI are already gone. + if (!canReleaseRakEthernet() || !fetch_store.resetSelection()) return false; + releaseRakIdleServingBuffers(); + rak4631_set_ethernet_spi_owner(false); + refreshRakStorageCapabilities(); + return true; + } +#endif + #if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE) OtaStoreSdNrf52& sdStagingStore() { #if defined(OTA_TOWER_AUTO_STORE) diff --git a/src/helpers/ota/OtaDeflate.cpp b/src/helpers/ota/OtaDeflate.cpp index a52d8758..18f56893 100644 --- a/src/helpers/ota/OtaDeflate.cpp +++ b/src/helpers/ota/OtaDeflate.cpp @@ -1,4 +1,7 @@ #include "OtaDeflate.h" +#if defined(RAK4631_COMBINED_ETHERNET) + #include "../nrf52/Rak4631SharedSpi.h" +#endif #if defined(ENABLE_OTA) || defined(OTA_TRANSPORT_DEFLATE_TEST) @@ -30,6 +33,11 @@ bool ota_transport_deflate(void* context, const uint8_t* src, uint16_t src_len, uint8_t* dst, uint16_t dst_cap, uint16_t* dst_len) { (void)context; if (dst_len) *dst_len = 0; +#if defined(RAK4631_COMBINED_ETHERNET) + // The Ethernet worker and encoder workspace share the combined image's + // runtime RAM allowance. Refuse before tinydeflate can allocate its table. + if (rak4631_ethernet_owns_spi()) return false; +#endif if (!src || src_len < 3 || src_len > MESHCORE_OTA_DEFLATE_BLOCK_MAX || !dst || !dst_cap || !dst_len) return false; // Proof/output scratch is disjoint from the manager's raw input. Reject diff --git a/src/helpers/ota/OtaSelf.cpp b/src/helpers/ota/OtaSelf.cpp index 467011ca..aa89c64e 100644 --- a/src/helpers/ota/OtaSelf.cpp +++ b/src/helpers/ota/OtaSelf.cpp @@ -198,6 +198,11 @@ static uint32_t parse_fw_version(const char* s) { } bool ota_serve_self(OtaContext& c, uint32_t fw_version) { +#if defined(RAK4631_COMBINED_ETHERNET) + // Also cover diagnostic `ota dev serve self`, which bypasses the public + // default-self-serve gate. Ethernet owns the alternative RAM workspace. + if (rak4631_ethernet_owns_spi()) return false; +#endif // Derive our version from the build string when the caller didn't supply one, so the mOTA we advertise // carries a real version (was hard-coded 0 -> peers saw "v0.0.0"). A dev build with no dotted number // still reads 0 - the self-describing EndF identity (docs) is the durable fix for that. diff --git a/src/helpers/ota/OtaStoreAdaptiveNrf52.h b/src/helpers/ota/OtaStoreAdaptiveNrf52.h index 3b262709..2cfd7079 100644 --- a/src/helpers/ota/OtaStoreAdaptiveNrf52.h +++ b/src/helpers/ota/OtaStoreAdaptiveNrf52.h @@ -8,6 +8,9 @@ #include "OtaStoreFlashNrf52.h" #include "OtaStoreQspiNrf52.h" #include "OtaRakStoragePolicy.h" +#if defined(RAK4631_COMBINED_ETHERNET) + #include "../nrf52/Rak4631SharedSpi.h" +#endif #include namespace mesh { @@ -63,7 +66,12 @@ class OtaStoreAdaptiveNrf52 : public OtaStore { void select() const { if (_mode != UNSELECTED) return; +#if defined(RAK4631_COMBINED_ETHERNET) + const bool ethernet = rak4631_ethernet_owns_spi(); + const uint8_t detected = ethernet ? 0u : OtaStoreQspiNrf52::autoDetect(); +#else const uint8_t detected = OtaStoreQspiNrf52::autoDetect(); +#endif const OtaBlCaps caps = ota_bootloader_app_caps(); if (detected == 3u) { _mode = UNSAFE; @@ -92,6 +100,12 @@ class OtaStoreAdaptiveNrf52 : public OtaStore { caps.optional_app_storage == (OTA_BL_STORAGE_QSPI | OTA_BL_STORAGE_HEADER_W25)); if (choice == RakStorageChoice::Internal) { activateInternal(); +#if defined(RAK4631_COMBINED_ETHERNET) + if (ethernet) { + _reason = "Ethernet active; internal application deltas only"; + return; + } +#endif _reason = detected == 0u ? "no external NOR" : "external NOR fitted; internal bootloader"; return; @@ -103,6 +117,12 @@ class OtaStoreAdaptiveNrf52 : public OtaStore { return; } _mode = UNSAFE; +#if defined(RAK4631_COMBINED_ETHERNET) + if (ethernet) { + _reason = "Ethernet active; bootloader lacks internal OTA"; + return; + } +#endif if (detected == 0u) { _reason = "QSPI bootloader but no matched external NOR"; } else if (!identity_valid) { @@ -114,16 +134,40 @@ class OtaStoreAdaptiveNrf52 : public OtaStore { OtaStore* active() { select(); +#if defined(RAK4631_COMBINED_ETHERNET) + if (_mode == QSPI_MODE && rak4631_ethernet_owns_spi()) return nullptr; +#endif return _mode == QSPI_MODE ? static_cast(&_storage.external) : _mode == INTERNAL_MODE ? static_cast(&_storage.internal) : nullptr; } const OtaStore* active() const { select(); +#if defined(RAK4631_COMBINED_ETHERNET) + if (_mode == QSPI_MODE && rak4631_ethernet_owns_spi()) return nullptr; +#endif return _mode == QSPI_MODE ? static_cast(&_storage.external) : _mode == INTERNAL_MODE ? static_cast(&_storage.internal) : nullptr; } public: +#if defined(RAK4631_COMBINED_ETHERNET) + // Only the main-loop handoff may reset the selection, after OtaContext has + // proved that no receive/apply session is active. Never discard staged data + // as a side effect of changing a network setting. + bool resetSelection() { + if (_mode == QSPI_MODE) { + if (_storage.external.staged_size() != 0u) return false; + _storage.external.~OtaStoreQspiNrf52(); + } else if (_mode == INTERNAL_MODE) { + if (_storage.internal.staged_size() != 0u) return false; + _storage.internal.~OtaStoreFlashNrf52(); + } + _mode = UNSELECTED; + _reason = "not probed"; + _bootloader_store = false; + return true; + } +#endif ~OtaStoreAdaptiveNrf52() override { if (_mode == QSPI_MODE) _storage.external.~OtaStoreQspiNrf52(); else if (_mode == INTERNAL_MODE) _storage.internal.~OtaStoreFlashNrf52(); diff --git a/src/helpers/ota/OtaStoreQspiNrf52.cpp b/src/helpers/ota/OtaStoreQspiNrf52.cpp index 05d53204..43489c86 100644 --- a/src/helpers/ota/OtaStoreQspiNrf52.cpp +++ b/src/helpers/ota/OtaStoreQspiNrf52.cpp @@ -13,6 +13,9 @@ #include #endif #include +#if defined(RAK4631_COMBINED_ETHERNET) + #include "../nrf52/Rak4631SharedSpi.h" +#endif namespace mesh { namespace ota { @@ -324,6 +327,11 @@ static uint32_t probe_nor(uint8_t cs) { #if defined(OTA_RAK_AUTO_STORE) uint8_t OtaStoreQspiNrf52::autoDetect() { +#if defined(RAK4631_COMBINED_ETHERNET) + // Do not even bit-bang a JEDEC probe while the Ethernet task owns SPI. + // Leave the cached physical discovery unchanged for the next idle handoff. + if (rak4631_ethernet_owns_spi()) return 0u; +#endif if (auto_detection != 0xFFu) return auto_detection; const uint8_t w25_cs = arduino_to_physical(31); @@ -479,6 +487,12 @@ bool OtaStoreQspiNrf52::waitMemoryReady(uint32_t timeout_ms) { } bool OtaStoreQspiNrf52::ensureFlash() { +#if defined(RAK4631_COMBINED_ETHERNET) + if (rak4631_ethernet_owns_spi()) { + fail("Ethernet owns WisBlock SPI; external OTA unavailable"); + return false; + } +#endif if (_qspi_ready) return true; #if defined(OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF) @@ -613,7 +627,11 @@ bool OtaStoreQspiNrf52::ensureFlash() { } void OtaStoreQspiNrf52::releaseFlash() { - if (_qspi_awake && !_memory_operation_pending) { + bool send_power_down = _qspi_awake && !_memory_operation_pending; +#if defined(RAK4631_COMBINED_ETHERNET) + send_power_down = send_power_down && !rak4631_ethernet_owns_spi(); +#endif + if (send_power_down) { // The repeater can remain idle for hours after a capacity/status probe or // a completed checkpoint. Put the NOR into deep power-down before // releasing the nRF QSPI peripheral instead of leaving both active for @@ -632,7 +650,14 @@ void OtaStoreQspiNrf52::releaseFlash() { // disabling the peripheral. Trigger it after every successful ENABLE, even // when wake or JEDEC identification failed before _qspi_ready was set. if (_qspi_active) { - if (_memory_operation_pending) { +#if defined(RAK4631_COMBINED_ETHERNET) + // Also release the QSPI pin assignments after a successful operation so + // the Ethernet SPIM can safely acquire the same pads at an idle handoff. + const bool disconnect_pins = true; +#else + const bool disconnect_pins = _memory_operation_pending; +#endif + if (disconnect_pins) { // QSPI owns pin direction while enabled. Preload and configure CS# high // before releasing the peripheral so an unresolved NOR operation cannot // see a floating or asserted chip select during the handoff. @@ -645,7 +670,7 @@ void OtaStoreQspiNrf52::releaseFlash() { nrf_qspi_task_trigger(NRF_QSPI, NRF_QSPI_TASK_DEACTIVATE); nrf_qspi_disable(NRF_QSPI); nrf_qspi_event_clear(NRF_QSPI, NRF_QSPI_EVENT_READY); - if (_memory_operation_pending) { + if (disconnect_pins) { #if defined(OTA_QSPI_RAK3401_RADIO_BUS_HANDOFF) disconnect_qspi_pins(); #else diff --git a/src/helpers/ota/OtaStoreQspiNrf52.h b/src/helpers/ota/OtaStoreQspiNrf52.h index 346f740a..dfa739d9 100644 --- a/src/helpers/ota/OtaStoreQspiNrf52.h +++ b/src/helpers/ota/OtaStoreQspiNrf52.h @@ -6,10 +6,17 @@ #error "OTA_QSPI_STORE raw staging cannot share a QSPI chip with QSPIFLASH" #endif -#if defined(OTA_QSPI_SHARED_WISBLOCK_SPI) && defined(ETHERNET_ENABLED) +#if defined(OTA_QSPI_SHARED_WISBLOCK_SPI) && defined(ETHERNET_ENABLED) && \ + !defined(RAK4631_COMBINED_ETHERNET) #error "WisBlock SPI OTA staging cannot share the bus/chip-select with Ethernet" #endif +#if defined(RAK4631_COMBINED_ETHERNET) && \ + (!defined(NRF52_PLATFORM) || !defined(RAK_4631) || !defined(OTA_RAK_AUTO_STORE) || \ + !defined(OTA_QSPI_SHARED_WISBLOCK_SPI) || !defined(ETHERNET_ENABLED)) +#error "Combined RAK4631 Ethernet requires its adaptive OTA and shared-SPI ownership contract" +#endif + #if defined(OTA_QSPI_SHARED_WISBLOCK_SPI) && defined(OTA_SD_STORE) #error "WisBlock SPI OTA staging cannot share the bus/chip-select with SD staging" #endif diff --git a/src/helpers/ui/SSD1306Display.cpp b/src/helpers/ui/SSD1306Display.cpp index a8bfe5f0..0aaa0584 100644 --- a/src/helpers/ui/SSD1306Display.cpp +++ b/src/helpers/ui/SSD1306Display.cpp @@ -1,5 +1,16 @@ #include "SSD1306Display.h" +#if defined(NRF52_PLATFORM) && defined(RAK4631_COMBINED_ETHERNET) +// Adafruit_SSD1306::begin allocates WIDTH * ((HEIGHT + 7) / 8) once and +// reuses that framebuffer on later begin calls. The driver itself is embedded +// in the linked global display, not allocated on the heap. Even including +// that object again plus allocator overhead fits the combined 2 KiB bound. +static_assert(SSD1306Display::FRAMEBUFFER_BYTES == 1024, + "Update the combined RAK OLED framebuffer budget"); +static_assert(sizeof(SSD1306Display) + SSD1306Display::FRAMEBUFFER_BYTES + 32 <= 2048, + "Update the combined RAK OLED runtime budget"); +#endif + bool SSD1306Display::i2c_probe(TwoWire& wire, uint8_t addr) { wire.beginTransmission(addr); uint8_t error = wire.endTransmission(); diff --git a/src/helpers/ui/SSD1306Display.h b/src/helpers/ui/SSD1306Display.h index b27c9a62..0c387b9b 100644 --- a/src/helpers/ui/SSD1306Display.h +++ b/src/helpers/ui/SSD1306Display.h @@ -16,6 +16,11 @@ #endif class SSD1306Display : public DisplayDriver { +public: + static constexpr int16_t PANEL_WIDTH = 128; + static constexpr int16_t PANEL_HEIGHT = 64; + static constexpr size_t FRAMEBUFFER_BYTES = PANEL_WIDTH * ((PANEL_HEIGHT + 7) / 8); +private: Adafruit_SSD1306 display; bool _isOn; bool _panel_ready = false; @@ -32,8 +37,8 @@ class SSD1306Display : public DisplayDriver { bool i2c_probe(TwoWire& wire, uint8_t addr); void applyRotation(); public: - SSD1306Display(RefCountedDigitalPin* peripher_power=NULL) : DisplayDriver(128, 64), - display(128, 64, &Wire, PIN_OLED_RESET), + SSD1306Display(RefCountedDigitalPin* peripher_power=NULL) : DisplayDriver(PANEL_WIDTH, PANEL_HEIGHT), + display(PANEL_WIDTH, PANEL_HEIGHT, &Wire, PIN_OLED_RESET), _peripher_power(peripher_power) { _isOn = false; diff --git a/test/fixtures/rak4631_ethernet_runtime/mocks/Arduino.h b/test/fixtures/rak4631_ethernet_runtime/mocks/Arduino.h new file mode 100644 index 00000000..6db6536a --- /dev/null +++ b/test/fixtures/rak4631_ethernet_runtime/mocks/Arduino.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class Print { +public: + virtual ~Print() = default; + virtual size_t write(uint8_t) = 0; + virtual size_t write(const uint8_t* data, size_t size) { + size_t written = 0; + while (written < size && write(data[written])) ++written; + return written; + } + size_t write(const char* data) { + return write(reinterpret_cast(data), std::strlen(data)); + } + size_t print(const char* data) { return write(data); } + size_t println(const char* data = "") { return print(data) + print("\r\n"); } + size_t printf(const char* format, ...) { + char buffer[256]; + va_list args; + va_start(args, format); + const int length = vsnprintf(buffer, sizeof(buffer), format, args); + va_end(args); + assert(length >= 0 && length < static_cast(sizeof(buffer))); + return write(reinterpret_cast(buffer), static_cast(length)); + } +}; +class Stream : public Print { +public: + virtual int available() = 0; + virtual int read() = 0; + virtual int peek() = 0; + virtual void flush() = 0; + virtual int availableForWrite() { return 0; } +}; + +extern std::atomic mock_millis; +inline uint32_t millis() { return mock_millis.load(); } +inline uint32_t pdMS_TO_TICKS(uint32_t milliseconds) { return milliseconds; } +struct MockFicr { uint32_t DEVICEID[2] = {0x123456, 0}; }; +extern MockFicr mock_ficr; +#define NRF_FICR (&mock_ficr) +#define NRF_SPIM1 1 +#define WB_IO2 34 +#define OUTPUT 1 +#define HIGH 1 +#define LOW 0 +void pinMode(int pin, int mode); +void digitalWrite(int pin, int value); + +struct MockTask; +using TaskHandle_t = MockTask*; +using BaseType_t = int; +struct StaticTask_t { uint8_t bytes[192]; }; +constexpr int pdPASS = 1; +constexpr int pdTRUE = 1; +constexpr uint32_t portMAX_DELAY = UINT32_MAX; +extern std::recursive_mutex mock_scheduler_mutex; +#define taskENTER_CRITICAL() mock_scheduler_mutex.lock() +#define taskEXIT_CRITICAL() mock_scheduler_mutex.unlock() +BaseType_t xTaskCreate(void (*function)(void*), const char*, unsigned stack_words, + void* argument, int priority, TaskHandle_t* handle); +void xTaskNotifyGive(TaskHandle_t handle); +uint32_t ulTaskNotifyTake(int clear_on_exit, uint32_t ticks); +void vTaskDelay(uint32_t ticks); +void vTaskDelete(TaskHandle_t handle); + +void mock_bus_touch(); +void mock_worker_bus_touch(); +void mock_spi_begin(); +void mock_spi_end(); diff --git a/test/fixtures/rak4631_ethernet_runtime/mocks/RAK13800_W5100S.h b/test/fixtures/rak4631_ethernet_runtime/mocks/RAK13800_W5100S.h new file mode 100644 index 00000000..1df5b6df --- /dev/null +++ b/test/fixtures/rak4631_ethernet_runtime/mocks/RAK13800_W5100S.h @@ -0,0 +1,82 @@ +#pragma once +#include +#include + +class IPAddress { + uint32_t address; +public: + explicit IPAddress(uint32_t value = 0x2a01a8c0) : address(value) {} + uint8_t operator[](size_t index) const { return (address >> (8 * index)) & 255; } +}; +extern std::atomic mock_socket_connected[4]; +extern std::atomic mock_stop_count[4]; +extern std::atomic mock_accept_client; +extern std::string mock_ethernet_input; +extern std::string mock_ethernet_output; +extern std::atomic mock_write_capacity; +class EthernetClient : public Stream { + int socket_index = -1; +public: + EthernetClient() = default; + explicit EthernetClient(uint8_t index) : socket_index(index) {} + explicit operator bool() const { return socket_index >= 0 && socket_index < 4; } + uint8_t getSocketNumber() const { return socket_index < 0 ? 255 : socket_index; } + bool connected() const { + mock_bus_touch(); + return socket_index >= 0 && mock_socket_connected[socket_index].load(); + } + void setConnectionTimeout(uint16_t milliseconds) { assert(milliseconds == 50); } + void stop() { + assert(false && "vendor blocking socket stop must not run"); + } + int available() override { mock_bus_touch(); return mock_ethernet_input.size(); } + int read() override { + mock_bus_touch(); + if (mock_ethernet_input.empty()) return -1; + const uint8_t next = mock_ethernet_input.front(); + mock_ethernet_input.erase(0, 1); + return next; + } + int peek() override { + mock_bus_touch(); + return mock_ethernet_input.empty() ? -1 : static_cast(mock_ethernet_input.front()); + } + int availableForWrite() override { assert(false && "vendor free-size spin must not run"); return 0; } + size_t write(uint8_t value) override { return write(&value, 1); } + size_t write(const uint8_t* data, size_t size) override { + (void)data; (void)size; + assert(false && "vendor blocking socketSend must not run"); + return 0; + } + void flush() override { assert(false && "raw blocking flush must not be called"); } + using Print::write; +}; +class EthernetServer { +public: + explicit EthernetServer(int port) { assert(port == 23); } + void begin() { mock_worker_bus_touch(); } + EthernetClient accept() { + mock_bus_touch(); + if (!mock_accept_client.exchange(false)) return {}; + mock_socket_connected[0].store(true); + return EthernetClient(uint8_t{0}); + } +}; +enum { EthernetNoHardware = 0, EthernetPresent = 1, LinkOFF = 0, LinkON = 1 }; +class MockEthernet { +public: + std::atomic begin_result{1}; + std::atomic hardware{EthernetPresent}; + std::atomic maintain_result{0}; + std::atomic ip{0x2a01a8c0}; + std::atomic begin_count{0}; + std::atomic maintain_count{0}; + std::atomic begin_blocked{false}; + std::atomic maintain_blocked{false}; + void init(SPIClass&, int chip_select); + int begin(uint8_t* mac, int timeout, int response_timeout); + int hardwareStatus() const { mock_worker_bus_touch(); return hardware.load(); } + IPAddress localIP() const { mock_worker_bus_touch(); return IPAddress(ip.load()); } + int maintain(); +}; +extern MockEthernet Ethernet; diff --git a/test/fixtures/rak4631_ethernet_runtime/mocks/SPI.h b/test/fixtures/rak4631_ethernet_runtime/mocks/SPI.h new file mode 100644 index 00000000..05193f9b --- /dev/null +++ b/test/fixtures/rak4631_ethernet_runtime/mocks/SPI.h @@ -0,0 +1,11 @@ +#pragma once +#include +struct SPISettings {}; +class SPIClass { +public: + SPIClass(int, int, int, int) {} + void begin() { mock_spi_begin(); } + void end() { mock_spi_end(); } + void beginTransaction(const SPISettings&) { mock_bus_touch(); } + void endTransaction() { mock_bus_touch(); } +}; diff --git a/test/fixtures/rak4631_ethernet_runtime/mocks/helpers/UsbLogging.h b/test/fixtures/rak4631_ethernet_runtime/mocks/helpers/UsbLogging.h new file mode 100644 index 00000000..93d01754 --- /dev/null +++ b/test/fixtures/rak4631_ethernet_runtime/mocks/helpers/UsbLogging.h @@ -0,0 +1,14 @@ +#pragma once +#include +namespace mesh { +inline bool isUsbDebugLoggingEnabled() { return false; } +class NoUsbLog : public Stream { +public: + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + void flush() override {} + size_t write(uint8_t) override { assert(false); return 0; } +}; +inline Stream& usbLoggingPort() { static NoUsbLog port; return port; } +} diff --git a/test/fixtures/rak4631_ethernet_runtime/mocks/w5100.h b/test/fixtures/rak4631_ethernet_runtime/mocks/w5100.h new file mode 100644 index 00000000..cacac4a7 --- /dev/null +++ b/test/fixtures/rak4631_ethernet_runtime/mocks/w5100.h @@ -0,0 +1,92 @@ +#pragma once +#include +#include +#include + +#define SPI_ETHERNET_SETTINGS SPISettings{} +enum SockCMD { Sock_CLOSE = 0x10, Sock_SEND = 0x20, Sock_RECV = 0x40 }; +struct SnSR { enum { CLOSED = 0, ESTABLISHED = 0x17, CLOSE_WAIT = 0x1c }; }; +struct SnIR { enum { SEND_OK = 0x10, TIMEOUT = 8 }; }; +extern SPIClass* mock_w5100_spi; +extern std::atomic mock_ack_send; +extern std::atomic mock_stuck_command; +extern std::atomic mock_hold_terminal_recv; +extern std::atomic mock_replace_during_parser; +void mock_replace_while_parser_active(); +extern std::mutex mock_output_mutex; +class MockW5100 { +public: + static constexpr uint16_t SSIZE = 2048; + static constexpr uint16_t SMASK = SSIZE - 1; + std::array, 4> memory{}; + uint16_t tx_wr[4] = {}, committed[4] = {}; + uint16_t rx_rd[4] = {}; + uint8_t interrupt[4] = {}; + mutable uint8_t command[4] = {}; + uint8_t getChip() const { return 51; } + SPIClass* getSPI() const { return mock_w5100_spi; } + uint16_t SBASE(uint8_t socket) const { return 0x4000 + socket * SSIZE; } + uint16_t RBASE(uint8_t socket) const { return 0x6000 + socket * SSIZE; } + uint8_t readSnSR(uint8_t socket) const { + mock_bus_touch(); + return mock_socket_connected[socket].load() ? SnSR::ESTABLISHED : SnSR::CLOSED; + } + uint8_t readSnIR(uint8_t socket) const { mock_bus_touch(); return interrupt[socket]; } + uint8_t readSnCR(uint8_t socket) const { + mock_bus_touch(); + if (command[socket] == Sock_RECV && !mock_stuck_command.load() && !mock_hold_terminal_recv.load()) { + command[socket] = 0; + } + return command[socket]; + } + uint16_t readSnTX_FSR(uint8_t) const { mock_bus_touch(); return mock_write_capacity.load(); } + uint16_t readSnTX_WR(uint8_t socket) const { mock_bus_touch(); return tx_wr[socket]; } + uint16_t readSnRX_RSR(uint8_t) const { mock_bus_touch(); return mock_ethernet_input.size(); } + uint16_t readSnRX_RD(uint8_t socket) const { mock_bus_touch(); return rx_rd[socket]; } + void writeSnRX_RD(uint8_t socket, uint16_t value) { mock_bus_touch(); rx_rd[socket] = value; } + void writeSnTX_WR(uint8_t socket, uint16_t value) { mock_bus_touch(); tx_wr[socket] = value; } + void writeSnIR(uint8_t socket, uint8_t bits) { mock_bus_touch(); interrupt[socket] &= ~bits; } + uint16_t write(uint16_t address, const uint8_t* data, uint16_t count) { + mock_bus_touch(); + const uint16_t relative = address - 0x4000; + const uint8_t socket = relative / SSIZE; + const uint16_t offset = relative % SSIZE; + assert(socket < 4 && offset + count <= SSIZE); + memcpy(memory[socket].data() + offset, data, count); + return count; + } + uint16_t read(uint16_t address, uint8_t* data, uint16_t count) { + mock_bus_touch(); + assert(address >= 0x6000 && count == 1 && !mock_ethernet_input.empty()); + data[0] = static_cast(mock_ethernet_input.front()); + return 1; + } + void writeSnCR(uint8_t socket, uint8_t value) { + mock_bus_touch(); + assert(command[socket] == 0 && "commands must never overwrite a stalled controller command"); + if (value == Sock_RECV) { + assert(!mock_ethernet_input.empty()); + if (mock_replace_during_parser.exchange(false)) mock_replace_while_parser_active(); + const char consumed = mock_ethernet_input.front(); + mock_ethernet_input.erase(0, 1); + command[socket] = mock_stuck_command.load() || (consumed == '\r' && mock_hold_terminal_recv.load()) ? Sock_RECV : 0; + return; + } + if (value == Sock_CLOSE) { + ++mock_stop_count[socket]; + mock_socket_connected[socket].store(false); + return; + } + assert(value == Sock_SEND); + { + std::lock_guard lock(mock_output_mutex); + for (uint16_t pointer = committed[socket]; pointer != tx_wr[socket]; ++pointer) { + mock_ethernet_output.push_back(memory[socket][pointer & SMASK]); + } + } + committed[socket] = tx_wr[socket]; + command[socket] = mock_stuck_command.load() ? Sock_SEND : 0; + interrupt[socket] = mock_ack_send.load() ? SnIR::SEND_OK : 0; + } +}; +extern MockW5100 W5100; diff --git a/test/fixtures/rak4631_ethernet_runtime/test.cpp b/test/fixtures/rak4631_ethernet_runtime/test.cpp new file mode 100644 index 00000000..763c41c4 --- /dev/null +++ b/test/fixtures/rak4631_ethernet_runtime/test.cpp @@ -0,0 +1,737 @@ +// Runs the actual opt-in production Ethernet helper on a fake RTOS and SPI +// device. Threads deliberately hold DHCP calls while the mesh loop continues. +#include +#include +#include +#include +#include +#include +#include +#include + +std::atomic mock_millis{0}; +MockFicr mock_ficr; +std::recursive_mutex mock_scheduler_mutex; +std::atomic mock_socket_connected[4]; +std::atomic mock_stop_count[4]; +std::atomic mock_accept_client{false}; +std::string mock_ethernet_input; +std::string mock_ethernet_output; +std::atomic mock_write_capacity{2048}; +MockEthernet Ethernet; +SPIClass* mock_w5100_spi = nullptr; +std::atomic mock_ack_send{true}; +std::atomic mock_stuck_command{false}; +std::atomic mock_hold_terminal_recv{false}; +std::atomic mock_replace_during_parser{false}; +static std::atomic worker_access_waits{0}; +std::mutex mock_output_mutex; +static const std::thread::id main_thread = std::this_thread::get_id(); +static std::atomic bus_owner{false}; +static std::atomic spi_active{false}; +static std::atomic hardware_operations{0}; +static std::atomic main_hardware_operations{0}; +static std::atomic live_task_allocations{0}; +static std::atomic task_deletions{0}; +static bool fail_task_creation = false; +static std::atomic defer_task_start{false}; +static std::mutex library_mutex; +static std::condition_variable library_changed; + +struct TaskDeleted {}; +struct MockTask { + std::thread thread; + std::mutex mutex; + std::condition_variable changed; + unsigned notifications = 0; + bool deleted = false; +}; +static thread_local MockTask* current_task = nullptr; +static std::vector> tasks; + +#include +MockW5100 W5100; + +static void wait_until(const std::function& condition) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (!condition()) { + assert(std::chrono::steady_clock::now() < deadline && "worker did not reach expected state"); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } +} +void mock_bus_touch() { + assert(bus_owner.load() && spi_active.load()); + assert(ethernet_spi_access.load() != EthernetSpiAccess::Idle); + ++hardware_operations; + if (std::this_thread::get_id() == main_thread) ++main_hardware_operations; +} +void mock_worker_bus_touch() { + assert(current_task != nullptr && std::this_thread::get_id() != main_thread); + assert(ethernet_spi_access.load() == EthernetSpiAccess::Worker); + mock_bus_touch(); +} +void mock_spi_begin() { + assert(current_task != nullptr && bus_owner.load()); + assert(!spi_active.exchange(true)); + ++hardware_operations; +} +void mock_spi_end() { + assert(bus_owner.load()); + assert(spi_active.exchange(false)); + ++hardware_operations; +} +void pinMode(int pin, int mode) { assert(pin == 21 && mode == OUTPUT); } +void digitalWrite(int pin, int value) { + assert((pin == 21 || pin == 26) && value == HIGH); +} +BaseType_t xTaskCreate(void (*function)(void*), const char* name, unsigned words, + void* argument, int priority, TaskHandle_t* handle) { + assert(std::this_thread::get_id() == main_thread); + assert(std::string(name) == "eth_init" && words == 1024 && priority == 1); + if (fail_task_creation) return 0; + auto created = std::make_unique(); + MockTask* raw = created.get(); + *handle = raw; + ++live_task_allocations; + raw->thread = std::thread([raw, function, argument] { + current_task = raw; + try { + { std::lock_guard lock(mock_scheduler_mutex); } + { + std::unique_lock lock(library_mutex); + library_changed.wait(lock, [] { return !defer_task_start.load(); }); + } + function(argument); + assert(false && "worker must park for main-thread deletion"); + } catch (const TaskDeleted&) {} + }); + tasks.push_back(std::move(created)); + return pdPASS; +} +void xTaskNotifyGive(TaskHandle_t handle) { + assert(handle != nullptr); + std::lock_guard lock(handle->mutex); + assert(!handle->deleted && "stale task notification"); + ++handle->notifications; + handle->changed.notify_all(); +} +uint32_t ulTaskNotifyTake(int clear_on_exit, uint32_t ticks) { + assert(current_task != nullptr && clear_on_exit == pdTRUE); + std::unique_lock lock(current_task->mutex); + const auto ready = [] { return current_task->notifications || current_task->deleted; }; + if (ticks == portMAX_DELAY) current_task->changed.wait(lock, ready); + else if (!current_task->changed.wait_for(lock, std::chrono::milliseconds(ticks), ready)) { + mock_millis.fetch_add(ticks); + } + if (current_task->deleted) throw TaskDeleted{}; + const uint32_t notifications = current_task->notifications; + current_task->notifications = 0; + return notifications; +} +void vTaskDelay(uint32_t ticks) { + assert(current_task != nullptr); + if (ticks == portMAX_DELAY) { + std::unique_lock lock(current_task->mutex); + current_task->changed.wait(lock, [] { return current_task->deleted; }); + throw TaskDeleted{}; + } + if (ticks == 1) ++worker_access_waits; + std::this_thread::sleep_for(std::chrono::milliseconds(ticks)); +} +void vTaskDelete(TaskHandle_t handle) { + assert(std::this_thread::get_id() == main_thread && handle != nullptr); + assert(ethernet_worker_stopped.load() && !spi_active.load()); + assert(bus_owner.load() && "owner must remain held until stack reclaimed"); + { + std::lock_guard lock(handle->mutex); + assert(!handle->deleted); + handle->deleted = true; + handle->changed.notify_all(); + } + handle->thread.join(); + --live_task_allocations; + ++task_deletions; +} +int MockEthernet::begin(uint8_t* mac, int timeout, int response_timeout) { + mock_worker_bus_touch(); + assert(mac[0] == 2 && mac[3] == 0x12 && mac[4] == 0x34 && mac[5] == 0x56); + assert(timeout == 10000 && response_timeout == 2000); + ++begin_count; + std::unique_lock lock(library_mutex); + library_changed.wait(lock, [this] { return !begin_blocked.load(); }); + return begin_result.load(); +} +void MockEthernet::init(SPIClass& spi, int chip_select) { + assert(chip_select == 26); + mock_worker_bus_touch(); + mock_w5100_spi = &spi; + W5100 = MockW5100{}; +} +int MockEthernet::maintain() { + mock_worker_bus_touch(); + ++maintain_count; + std::unique_lock lock(library_mutex); + library_changed.wait(lock, [this] { return !maintain_blocked.load(); }); + return maintain_result.load(); +} + +struct Context { + bool prepare_allowed = true; + bool off_allowed = true; + bool release_allowed = true; + bool save_allowed = true; + unsigned prepares = 0, releases = 0, saves = 0; +}; +static bool prepare(void* opaque, char* error, size_t capacity) { + assert(std::this_thread::get_id() == main_thread); + auto& context = *static_cast(opaque); + ++context.prepares; + assert(!bus_owner.load() && !spi_active.load() && !live_task_allocations.load()); + if (!context.prepare_allowed) { + snprintf(error, capacity, "Error: flash conflict or active OTA"); + return false; + } + bus_owner.store(true); + return true; +} +static bool can_release(void* opaque) { + assert(std::this_thread::get_id() == main_thread); + return static_cast(opaque)->off_allowed; +} +static bool release(void* opaque) { + assert(std::this_thread::get_id() == main_thread); + auto& context = *static_cast(opaque); + ++context.releases; + assert(bus_owner.load() && !spi_active.load()); + assert(live_task_allocations.load() == 0 && "OTA workspace cannot overlap deferred task memory"); + if (!context.release_allowed) return false; + bus_owner.store(false); + return true; +} +static bool save(void* opaque, bool) { + assert(std::this_thread::get_id() == main_thread); + auto& context = *static_cast(opaque); + ++context.saves; + return context.save_allowed; +} +static void configure(Context& context, bool persisted = false, bool saved_on = false) { + EthernetCliHooks hooks; + hooks.prepare = prepare; + hooks.can_release = can_release; + hooks.released = release; + hooks.save = persisted ? save : nullptr; + hooks.context = &context; + assert(ethernet_configure(hooks, saved_on)); +} +static std::string command(const char* text) { + char reply[160] = {}; + // Commands used directly by this fixture stand in for a complete input line. + ethernet_reply_generation = ethernet_session_generation.load(); + assert(ethernet_handle_command(text, reply)); + return reply; +} +static void start_online() { + assert(command("eth on").find("starting until restart") != std::string::npos); + wait_until([] { return ethernet_is_running(); }); + assert(ethernet_state.load() == EthernetCliState::Online); + assert(ethernet_worker_active.load() && live_task_allocations.load() == 1); +} +static void assert_busy_facade() { + const int before = main_hardware_operations.load(); + assert(!ethernet_client.connected() && !static_cast(ethernet_client)); + assert(ethernet_client.available() == 0 && ethernet_client.availableForWrite() == 0); + assert(ethernet_client.read() == -1 && ethernet_client.peek() == -1); + assert(ethernet_client.write(uint8_t{1}) == 0); + const uint8_t bytes[] = {1, 2, 3}; + Stream* retained_mesh_output = ðernet_client; + assert(retained_mesh_output->write(bytes, sizeof(bytes)) == 0); + retained_mesh_output->flush(); + ethernet_client.stop(); + char line[32] = {}; + assert(!ethernet_read_line(line, sizeof(line))); + ethernet_send_reply("paused"); + assert(main_hardware_operations.load() == before); +} +static void finish_off() { + assert(command("eth off").find("until restart") != std::string::npos); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(3); + while (ethernet_state.load() != EthernetCliState::Off) { + ethernet_loop_maintain(); + assert(std::chrono::steady_clock::now() < deadline); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + assert(!bus_owner.load() && !spi_active.load() && live_task_allocations.load() == 0); + assert(!ethernet_get_enabled() && !ethernet_is_running()); + assert(ethernet_task_handle == nullptr); +} +static void unblock(std::atomic& flag) { flag.store(false); library_changed.notify_all(); } +static std::string output() { + std::lock_guard lock(mock_output_mutex); + return mock_ethernet_output; +} +void mock_replace_while_parser_active() { + const uint32_t before = ethernet_session_generation.load(); + const int previous_waits = worker_access_waits.load(); + mock_accept_client.store(true); + wait_until([&] { return worker_access_waits.load() > previous_waits; }); + // The background worker attempted to acquire SPI during this record. It + // must remain excluded until both input and reply-owner capture finish. + assert(ethernet_spi_access.load() == EthernetSpiAccess::Client); + assert(ethernet_session_generation.load() == before); +} +static void accept_connection() { + mock_accept_client.store(true); + ethernet_loop_maintain(); + wait_until([] { return output().find("MeshCore CLI\r\n") != std::string::npos && ethernet_is_running(); }); +} + +int main(int argc, char** argv) { + assert(argc == 2); + const int scenario = std::stoi(argv[1]); + Context context; + configure(context); + switch (scenario) { + case 0: { // Disabled boot, strict parser and mandatory bus hooks. + ethernet_start_task(); + assert(command("get eth") == "> off"); + assert(command("eth.status") == "ETH: off"); + assert(command("eth true").find("must be on or off") != std::string::npos); + assert(command("set eth on extra").find("must be on or off") != std::string::npos); + char reply[160]; + assert(!ethernet_handle_command("eth.status extra", reply)); + assert_busy_facade(); + EthernetCliHooks missing; + assert(ethernet_configure(missing)); + assert(command("eth on").find("hooks unavailable") != std::string::npos); + assert(hardware_operations.load() == 0 && context.prepares == 0); + break; + } + case 1: { // OTA or known flash conflict refuses before any SPI operation. + context.prepare_allowed = false; + assert(command("set eth on").find("flash conflict") != std::string::npos); + assert(!ethernet_get_enabled() && context.prepares == 1 && !bus_owner.load()); + assert(hardware_operations.load() == 0); + break; + } + case 2: { // Allocation failure releases ownership without a deferred task. + fail_task_creation = true; + assert(command("eth on").find("allocation failed") != std::string::npos); + assert(context.releases == 1 && !bus_owner.load() && !ethernet_worker_active.load()); + assert(command("eth.status") == "ETH: start failed"); + assert(hardware_operations.load() == 0); + break; + } + case 3: { // OFF before worker gets scheduled never initializes hardware. + defer_task_start.store(true); + assert(command("eth on").find("starting") != std::string::npos); + assert(command("eth off").find("stopping") != std::string::npos); + assert(bus_owner.load() && context.releases == 0); + unblock(defer_task_start); + wait_until([] { return ethernet_worker_stopped.load(); }); + assert(hardware_operations.load() == 0 && task_deletions.load() == 0); + ethernet_loop_maintain(); + assert(task_deletions.load() == 1 && context.releases == 1 && !bus_owner.load()); + break; + } + case 4: { // OFF during blocking DHCP is cooperative and cannot release SPI early. + Ethernet.begin_blocked.store(true); + assert(command("eth on").find("starting") != std::string::npos); + wait_until([] { return Ethernet.begin_count.load() == 1; }); + assert_busy_facade(); + assert(command("eth off").find("stopping") != std::string::npos); + ethernet_loop_maintain(); + assert(context.releases == 0 && bus_owner.load() && task_deletions.load() == 0); + unblock(Ethernet.begin_blocked); + wait_until([] { return ethernet_worker_stopped.load(); }); + ethernet_loop_maintain(); + assert(task_deletions.load() == 1 && context.releases == 1 && !bus_owner.load()); + for (const auto& count : mock_stop_count) assert(count.load() == 1); + break; + } + case 5: { // OFF wakes the 30-second retry wait rather than waiting its timeout. + Ethernet.begin_result.store(0); + assert(command("eth on").find("starting") != std::string::npos); + wait_until([] { return ethernet_state.load() == EthernetCliState::Retrying; }); + assert(command("eth.status") == "ETH: waiting for DHCP"); + finish_off(); + assert(Ethernet.begin_count.load() == 1 && task_deletions.load() == 1); + break; + } + case 6: { // Absent hardware releases its parked task in the main loop. + Ethernet.begin_result.store(0); + Ethernet.hardware.store(EthernetNoHardware); + assert(command("eth on").find("starting") != std::string::npos); + wait_until([] { return ethernet_worker_stopped.load(); }); + assert(command("eth.status") == "ETH: hardware not found"); + assert(context.releases == 0 && live_task_allocations.load() == 1); + ethernet_loop_maintain(); + assert(!bus_owner.load() && context.releases == 1 && live_task_allocations.load() == 0); + for (const auto& count : mock_stop_count) assert(count.load() == 0); + break; + } + case 7: { // Online framing, bounded writes and real disconnect reset the retained stream. + start_online(); + assert(command("get eth") == "> on"); + assert(command("eth on").find("already on") != std::string::npos); + const int before_status = hardware_operations.load(); + assert(command("eth.status") == "ETH: 192.168.1.42:23"); + assert(hardware_operations.load() == before_status); + accept_connection(); + assert(ethernet_take_session_reset() && !ethernet_take_session_reset()); + assert(ethernet_client.connected()); + mock_ethernet_input = "eth.status\r\n"; + char line[32] = {}; + assert(ethernet_read_line(line, sizeof(line)) && std::string(line) == "eth.status"); + wait_until([] { return ethernet_is_running(); }); + assert(ethernet_client.write(reinterpret_cast("abcde"), 5) == 5); + wait_until([] { return output().find("abcde") != std::string::npos && ethernet_is_running(); }); + mock_socket_connected[0].store(false); + wait_until([] { return ethernet_session_reset.load() && !ethernet_client_attached.load(); }); + wait_until([] { return ethernet_is_running(); }); + assert(ethernet_take_session_reset() && !ethernet_client.connected()); + finish_off(); + break; + } + case 8: { // Renewal owns SPI; every retained Stream call yields without raw IO. + start_online(); + Ethernet.maintain_blocked.store(true); + mock_millis.store(1000); + const auto before = std::chrono::steady_clock::now(); + ethernet_loop_maintain(); + assert(std::chrono::steady_clock::now() - before < std::chrono::milliseconds(100)); + wait_until([] { return Ethernet.maintain_count.load() == 1; }); + assert(command("eth.status") == "ETH: renewing DHCP"); + assert_busy_facade(); + for (int index = 0; index < 20; ++index) ethernet_loop_maintain(); + assert(context.releases == 0 && !ethernet_take_session_reset()); + unblock(Ethernet.maintain_blocked); + wait_until([] { return ethernet_is_running(); }); + assert(command("eth.status") == "ETH: 192.168.1.42:23"); + finish_off(); + break; + } + case 9: { // Failed lease renewal does not block mesh service or restart the worker. + start_online(); + Ethernet.maintain_result.store(1); + Ethernet.ip.store(0x3301a8c0); + mock_millis.store(1000); + ethernet_loop_maintain(); + wait_until([] { return Ethernet.maintain_count.load() == 1 && ethernet_is_running(); }); + assert(command("eth.status") == "ETH: 192.168.1.51:23"); + assert(Ethernet.begin_count.load() == 1 && live_task_allocations.load() == 1); + finish_off(); + break; + } + case 10: { // Active or staged OTA refuses OFF without affecting the active transport. + start_online(); + context.off_allowed = false; + assert(command("eth off").find("locked by OTA") != std::string::npos); + assert(ethernet_get_enabled() && ethernet_is_running() && bus_owner.load()); + assert(context.releases == 0 && task_deletions.load() == 0); + context.off_allowed = true; + finish_off(); + break; + } + case 11: { // Ethernet-origin OFF can acknowledge before the next loop gates the bus. + start_online(); + accept_connection(); + const std::string reply = command("eth off"); + assert(reply.find("stopping until restart") != std::string::npos); + ethernet_send_reply(reply.c_str()); + assert(context.releases == 0 && task_deletions.load() == 0); + ethernet_loop_maintain(); + wait_until([] { return ethernet_worker_stopped.load(); }); + assert(output().find(reply) != std::string::npos); + assert_busy_facade(); + assert(live_task_allocations.load() == 1 && bus_owner.load()); + ethernet_loop_maintain(); + assert(task_deletions.load() == 1 && context.releases == 1 && !bus_owner.load()); + for (const auto& count : mock_stop_count) assert(count.load() >= 1); + assert(ethernet_take_session_reset()); + break; + } + case 12: { // OTA race after preflight retains owner while release retries on the main loop. + start_online(); + context.release_allowed = false; + assert(command("eth off").find("stopping") != std::string::npos); + ethernet_loop_maintain(); + wait_until([] { return ethernet_worker_stopped.load(); }); + ethernet_loop_maintain(); + assert(task_deletions.load() == 1 && live_task_allocations.load() == 0); + assert(bus_owner.load() && !spi_active.load() && context.releases == 1); + assert(command("eth on").find("still stopping") != std::string::npos); + ethernet_loop_maintain(); + assert(context.releases == 2 && bus_owner.load()); + context.release_allowed = true; + ethernet_loop_maintain(); + assert(context.releases == 3 && !bus_owner.load()); + assert(ethernet_state.load() == EthernetCliState::Off); + break; + } + case 13: { // Optional save failures are transactional; prototype has no save hook. + configure(context, true); + context.save_allowed = false; + assert(command("eth on").find("cannot save") != std::string::npos); + assert(!bus_owner.load() && hardware_operations.load() == 0 && context.saves == 1); + context.save_allowed = true; + assert(command("eth on") == "OK - Ethernet starting; use eth.status"); + wait_until([] { return ethernet_is_running(); }); + context.save_allowed = false; + assert(command("eth off").find("cannot save") != std::string::npos); + assert(ethernet_get_enabled() && ethernet_is_running()); + context.save_allowed = true; + assert(command("eth off") == "OK - Ethernet stopping"); + ethernet_loop_maintain(); + wait_until([] { return ethernet_worker_stopped.load(); }); + ethernet_loop_maintain(); + assert(!bus_owner.load() && live_task_allocations.load() == 0); + break; + } + case 14: { // Repeated hot switches reclaim each worker and cannot notify a stale handle. + for (int cycle = 0; cycle < 4; ++cycle) { + start_online(); + assert(!ethernet_configure(EthernetCliHooks{})); + finish_off(); + ethernet_notify_worker(); + } + assert(task_deletions.load() == 4 && context.prepares == 4 && context.releases == 4); + assert(Ethernet.begin_count.load() == 4); + break; + } + case 15: { // A future saved-ON configuration starts without writing its settings again. + configure(context, true, true); + ethernet_start_task(); + wait_until([] { return ethernet_is_running(); }); + assert(context.saves == 0 && context.prepares == 1); + assert(command("eth off") == "OK - Ethernet stopping"); + ethernet_loop_maintain(); + wait_until([] { return ethernet_worker_stopped.load(); }); + ethernet_loop_maintain(); + assert(context.saves == 1 && !bus_owner.load()); + break; + } + case 16: { // OFF during a slow renewal leaves bus reserved until that library call returns. + start_online(); + Ethernet.maintain_blocked.store(true); + mock_millis.store(1000); + ethernet_loop_maintain(); + wait_until([] { return Ethernet.maintain_count.load() == 1; }); + assert(command("eth off").find("stopping") != std::string::npos); + for (int index = 0; index < 10; ++index) ethernet_loop_maintain(); + assert_busy_facade(); + assert(context.releases == 0 && task_deletions.load() == 0 && bus_owner.load()); + unblock(Ethernet.maintain_blocked); + wait_until([] { return ethernet_worker_stopped.load(); }); + ethernet_loop_maintain(); + assert(context.releases == 1 && task_deletions.load() == 1 && !bus_owner.load()); + break; + } + case 17: { // Bounded ring queue applies backpressure and preserves wrap-around order. + start_online(); + accept_connection(); + (void)ethernet_take_session_reset(); + mock_write_capacity.store(0); + const std::string first(400, 'a'); + const std::string second(400, 'b'); + assert(ethernet_client.write(reinterpret_cast(first.data()), first.size()) == first.size()); + assert(ethernet_client.write(reinterpret_cast(second.data()), second.size()) == 112); + assert(ethernet_client.availableForWrite() == 0); + assert(ethernet_client.write(uint8_t{'x'}) == 0); + mock_ethernet_input = "eth off\r"; + char line[32] = {}; + assert(!ethernet_read_line(line, sizeof(line)) && mock_ethernet_input == "eth off\r"); + mock_write_capacity.store(2048); + wait_until([&] { return output().find(first + second.substr(0, 112)) != std::string::npos && ethernet_is_running(); }); + assert(ethernet_client.write(reinterpret_cast(second.data() + 112), 288) == 288); + wait_until([&] { return output().find(first + second) != std::string::npos && ethernet_is_running(); }); + assert(ethernet_read_line(line, sizeof(line)) && std::string(line) == "eth off"); + finish_off(); + break; + } + case 18: { // Oversized records cannot execute their truncated command prefix. + start_online(); + accept_connection(); + (void)ethernet_take_session_reset(); + mock_ethernet_input = "eth off" + std::string(80, 'x') + "\rget eth\r"; + char line[32] = {}; + assert(!ethernet_read_line(line, sizeof(line)) && line[0] == 0); + assert(ethernet_get_enabled() && ethernet_take_session_reset()); + wait_until([] { return ethernet_is_running(); }); + assert(ethernet_read_line(line, sizeof(line)) && std::string(line) == "get eth"); + wait_until([] { return output().find("invalid or oversized command; discarded") != std::string::npos && ethernet_is_running(); }); + line[0] = 0; + mock_ethernet_input = std::string(31, 'm') + "\r"; + assert(ethernet_read_line(line, sizeof(line)) && std::strlen(line) == 31); + finish_off(); + break; + } + case 19: { // Embedded NUL discards the entire record, including a valid-looking prefix. + start_online(); + accept_connection(); + (void)ethernet_take_session_reset(); + mock_ethernet_input = std::string("eth off\0junk", 12) + "\rget eth\r"; + char line[32] = {}; + assert(!ethernet_read_line(line, sizeof(line)) && line[0] == 0); + assert(ethernet_get_enabled() && ethernet_take_session_reset()); + wait_until([] { return ethernet_is_running(); }); + assert(ethernet_read_line(line, sizeof(line)) && std::string(line) == "get eth"); + finish_off(); + break; + } + case 20: { // Missing TCP ACK retains Worker ownership and cannot block retained streams. + start_online(); + accept_connection(); + (void)ethernet_take_session_reset(); + mock_ack_send.store(false); + assert(ethernet_client.write(reinterpret_cast("payload"), 7) == 7); + wait_until([] { return !ethernet_is_running(); }); + assert_busy_facade(); + for (int iteration = 0; iteration < 10; ++iteration) ethernet_loop_maintain(); + wait_until([] { return ethernet_is_running() && !ethernet_client_attached.load(); }); + assert(ethernet_take_session_reset() && output().find("payload") != std::string::npos); + assert(ethernet_worker_active.load() && bus_owner.load()); + finish_off(); + break; + } + case 21: { // A stuck SEND register faults safely rather than exposing it to vendor RX/accept. + start_online(); + accept_connection(); + (void)ethernet_take_session_reset(); + mock_ack_send.store(false); + mock_stuck_command.store(true); + assert(ethernet_client.write(uint8_t{'z'}) == 1); + wait_until([] { return ethernet_state.load() == EthernetCliState::Faulted; }); + assert(command("eth.status").find("controller stalled") != std::string::npos); + assert_busy_facade(); + const int before = hardware_operations.load(); + mock_accept_client.store(true); + for (int iteration = 0; iteration < 20; ++iteration) ethernet_loop_maintain(); + assert(hardware_operations.load() == before && bus_owner.load()); + finish_off(); + assert(task_deletions.load() == 1); + mock_stuck_command.store(false); + mock_ack_send.store(true); + start_online(); + finish_off(); + break; + } + case 22: { // OFF transmits its queued acknowledgement, then bounds waiting for missing ACK. + start_online(); + accept_connection(); + mock_ack_send.store(false); + const std::string reply = command("eth off"); + ethernet_send_reply(reply.c_str()); + const auto started = std::chrono::steady_clock::now(); + ethernet_loop_maintain(); + wait_until([] { return ethernet_worker_stopped.load(); }); + assert(std::chrono::steady_clock::now() - started < std::chrono::seconds(1)); + assert(output().find(" -> " + reply + "\r\n") != std::string::npos); + ethernet_loop_maintain(); + assert(!bus_owner.load() && task_deletions.load() == 1); + break; + } + case 23: { // Discard is bounded across calls and never exposes a fragmented long record. + start_online(); + accept_connection(); + (void)ethernet_take_session_reset(); + mock_ethernet_input = std::string(220, 'x') + "\nget eth\n"; + char line[32] = {}; + assert(!ethernet_read_line(line, sizeof(line)) && line[0] == 0); + assert(mock_ethernet_input.size() == 101 && ethernet_line_discarding); + assert(!ethernet_read_line(line, sizeof(line)) && line[0] == 0); + assert(!ethernet_line_discarding); + (void)ethernet_take_session_reset(); + wait_until([] { return ethernet_is_running(); }); + assert(ethernet_read_line(line, sizeof(line)) && std::string(line) == "get eth"); + finish_off(); + break; + } + case 24: { // A newly stuck RECV command yields instead of entering vendor execCmdSn. + start_online(); + accept_connection(); + (void)ethernet_take_session_reset(); + mock_stuck_command.store(true); + mock_ethernet_input = "get eth\r"; + char line[32] = {}; + assert(!ethernet_read_line(line, sizeof(line)) && std::string(line) == "g"); + assert_busy_facade(); + wait_until([] { return ethernet_state.load() == EthernetCliState::Faulted; }); + assert(ethernet_get_enabled() && bus_owner.load()); + finish_off(); + break; + } + case 25: { // A delayed terminal RECV command does not lose its synchronous reply. + start_online(); + accept_connection(); + (void)ethernet_take_session_reset(); + mock_hold_terminal_recv.store(true); + mock_ethernet_input = "get eth\r"; + char line[32] = {}; + assert(ethernet_read_line(line, sizeof(line)) && std::string(line) == "get eth"); + assert(!ethernet_is_running()); + ethernet_send_reply("> on"); + assert(ethernet_tx_count >= std::strlen(" -> > on\r\n")); + mock_hold_terminal_recv.store(false); + wait_until([] { return output().find(" -> > on\r\n") != std::string::npos && ethernet_is_running(); }); + finish_off(); + break; + } + case 26: { // Replacement cancels retained async output and rejects the previous peer's reply. + start_online(); + accept_connection(); + (void)ethernet_take_session_reset(); + mock_ethernet_input = "get eth\r"; + char line[32] = {}; + assert(ethernet_read_line(line, sizeof(line))); + const uint32_t previous = ethernet_session_generation.load(); + mock_accept_client.store(true); + wait_until([&] { return ethernet_session_generation.load() != previous && ethernet_is_running(); }); + ethernet_send_reply("previous-peer-result"); + assert(ethernet_client.write(reinterpret_cast("old-async"), 9) == 0); + (void)ethernet_take_session_reset(); + finish_off(); + assert(output().find("previous-peer-result") == std::string::npos); + assert(output().find("old-async") == std::string::npos); + break; + } + case 27: { // A partial line from a replaced client cannot concatenate with the next peer. + start_online(); + accept_connection(); + (void)ethernet_take_session_reset(); + mock_ethernet_input = "eth "; + char line[32] = {}; + assert(!ethernet_read_line(line, sizeof(line)) && std::string(line) == "eth "); + const uint32_t before = ethernet_session_generation.load(); + mock_accept_client.store(true); + wait_until([&] { return ethernet_session_generation.load() != before && ethernet_is_running(); }); + mock_ethernet_input = "off\r"; + assert(!ethernet_read_line(line, sizeof(line)) && line[0] == 0); + assert(mock_ethernet_input == "off\r"); + (void)ethernet_take_session_reset(); + assert(ethernet_read_line(line, sizeof(line)) && std::string(line) == "off"); + finish_off(); + break; + } + case 28: { // Concurrent replacement cannot steal the record before its owner is captured. + start_online(); + accept_connection(); + (void)ethernet_take_session_reset(); + const uint32_t before = ethernet_session_generation.load(); + mock_ethernet_input = "get eth\r"; + mock_replace_during_parser.store(true); + char line[32] = {}; + assert(ethernet_read_line(line, sizeof(line)) && std::string(line) == "get eth"); + assert(ethernet_reply_generation == before && worker_access_waits.load() > 0); + wait_until([&] { return ethernet_session_generation.load() != before && ethernet_is_running(); }); + ethernet_send_reply("old-record-result"); + finish_off(); + assert(output().find("old-record-result") == std::string::npos); + break; + } + default: assert(false); + } + assert(live_task_allocations.load() == 0 && !spi_active.load() && !bus_owner.load()); + assert(ethernet_tx_queue == nullptr && ethernet_tx_count == 0); + for (const auto& task : tasks) assert(!task->thread.joinable()); + printf("PASS: RAK4631 Ethernet runtime scenario %d\n", scenario); +} diff --git a/test/fixtures/ssd1306_picopixel/render.cpp b/test/fixtures/ssd1306_picopixel/render.cpp index 49fb5c03..530af67d 100644 --- a/test/fixtures/ssd1306_picopixel/render.cpp +++ b/test/fixtures/ssd1306_picopixel/render.cpp @@ -21,6 +21,9 @@ int main(int argc, char** argv) { TestSSD1306Display d; assert(d.begin()); auto& c=*Adafruit_SSD1306::last; + assert(d.width()==128 && d.height()==64); + assert(c.width()==128 && c.height()==64); + static_assert(SSD1306Display::FRAMEBUFFER_BYTES==1024); const char* message="GIANT KILLER: Out on the Mercerwood mesh today, just the V4 and a small battery Checking the smaller font so the rest of this message is visible. 0123456789 END"; assert(std::string(message).size()==160); d.startFrame(); diff --git a/test/test_firmware_ram.py b/test/test_firmware_ram.py index 4b9f72b9..71680ae0 100644 --- a/test/test_firmware_ram.py +++ b/test/test_firmware_ram.py @@ -188,6 +188,103 @@ class FirmwareRamTest(unittest.TestCase): }, "v3_repeater") self.assertEqual(alternate, combined) + def test_combined_rak_budgets_uart_beside_exclusive_ethernet_and_ota(self): + defines = { + "RAK_4631": 1, "ETHERNET_ENABLED": 1, "OTA_RAK_AUTO_STORE": 1, + "ENABLE_OTA": 1, "MESHCORE_OTA_DEVICE_DEFLATE": 1, + "WITH_RS232_BRIDGE": "Serial2", "DISPLAY_CLASS": "SSD1306Display", + } + legacy = ram.requirements("NRF52_PLATFORM", defines, + "RAK_4631_repeater_unified_lora_ota") + combined = ram.requirements("NRF52_PLATFORM", { + **defines, "RAK4631_COMBINED_ETHERNET": 1, + }, "RAK_4631_repeater_unified_lora_ota") + components = combined["components"] + self.assertEqual(components["uart_bridge_and_driver"], 2560) + self.assertEqual(components["ethernet_or_external_ota_workspaces"], 5168) + self.assertNotIn("ota_encoder_workspace", components) + self.assertNotIn("ota_self_source_scratch", components) + self.assertEqual(components["display_pixels_and_driver"], 2048) + self.assertEqual(combined["required_heap_bytes"] - legacy["required_heap_bytes"], 2560 - 2048) + self.assertEqual(components["loop_and_callback_stacks"], 8192 + 3072) + self.assertEqual(components["flood_filter_table"], 63 * 200 + 16) + # An alternate port selects the same bridge instead of allocating two. + alternate = ram.requirements("NRF52_PLATFORM", { + **defines, "RAK4631_COMBINED_ETHERNET": 1, + "WITH_RS232_BRIDGE_ALT": "Serial1", + }, "RAK_4631_repeater_unified_lora_ota") + self.assertEqual(alternate, combined) + + def test_combined_rak_worker_budget_tracks_stack_and_encoder_overrides(self): + defines = { + "RAK_4631": 1, "ETHERNET_ENABLED": 1, "OTA_RAK_AUTO_STORE": 1, + "RAK4631_COMBINED_ETHERNET": 1, "ENABLE_OTA": 1, + "MESHCORE_OTA_DEVICE_DEFLATE": 1, + } + for words, expected in ((1024, 5168), (1536, 6976), (2048, 9024)): + with self.subTest(words=words): + policy = ram.requirements("NRF52_PLATFORM", { + **defines, "RAK4631_ETHERNET_TASK_STACK_WORDS": words, + }, "RAK_4631_sensor") + self.assertEqual(policy["components"]["ethernet_or_external_ota_workspaces"], expected) + self.assertGreaterEqual(policy["required_contiguous_bytes"], expected) + no_encoder = ram.requirements("NRF52_PLATFORM", { + **defines, "MESHCORE_OTA_DEVICE_DEFLATE": 0, + }, "RAK_4631_sensor") + self.assertEqual(no_encoder["components"]["ethernet_or_external_ota_workspaces"], 4928) + for bits in (7, 8, 9, 10): + with self.subTest(bits=bits): + policy = ram.requirements("NRF52_PLATFORM", { + **defines, "MESHCORE_OTA_DEFLATE_HASH_BITS": bits, + }, "RAK_4631_sensor") + self.assertEqual(policy["components"]["ethernet_or_external_ota_workspaces"], + max(4928, (1 << bits) * 2 + 16 + 4096 + 32)) + for capacity, expected in ((512, 5168), (1024, 5440), (2048, 6464)): + with self.subTest(tx_capacity=capacity): + policy = ram.requirements("NRF52_PLATFORM", { + **defines, "ETHERNET_CLI_TX_BUFFER_BYTES": capacity, + }, "RAK_4631_sensor") + self.assertEqual(policy["components"]["ethernet_or_external_ota_workspaces"], expected) + + def test_combined_rak_oled_bound_does_not_change_other_display_budgets(self): + defines = { + "RAK_4631": 1, "ETHERNET_ENABLED": 1, "OTA_RAK_AUTO_STORE": 1, + "RAK4631_COMBINED_ETHERNET": 1, + } + for driver, expected in (("SSD1306Display", 2048), ("SH1106Display", 4096), + ("SH1107Display", 4096), ("ST7735Display", 25602)): + with self.subTest(driver=driver): + combined = ram.requirements("NRF52_PLATFORM", { + **defines, "DISPLAY_CLASS": driver, + }, "RAK_4631_repeater") + self.assertEqual(combined["components"]["display_pixels_and_driver"], expected) + ordinary = ram.requirements("NRF52_PLATFORM", { + "DISPLAY_CLASS": "SSD1306Display", + }, "RAK_4631_repeater") + self.assertEqual(ordinary["components"]["display_pixels_and_driver"], 4096) + + def test_combined_rak_budget_rejects_unmatched_hardware_or_short_task_stack(self): + defines = { + "RAK_4631": 1, "ETHERNET_ENABLED": 1, "OTA_RAK_AUTO_STORE": 1, + "RAK4631_COMBINED_ETHERNET": 1, + } + for missing in ("RAK_4631", "ETHERNET_ENABLED", "OTA_RAK_AUTO_STORE"): + invalid = {name: value for name, value in defines.items() if name != missing} + with self.subTest(missing=missing), self.assertRaisesRegex(ValueError, "adaptive nRF52 storage"): + ram.requirements("NRF52_PLATFORM", invalid, "RAK_4631_repeater") + with self.assertRaisesRegex(ValueError, "adaptive nRF52 storage"): + ram.requirements("ESP32_PLATFORM", defines, "esp_repeater") + for words in (0, 512, 1023): + with self.subTest(words=words), self.assertRaisesRegex(ValueError, "1024 stack words"): + ram.requirements("NRF52_PLATFORM", { + **defines, "RAK4631_ETHERNET_TASK_STACK_WORDS": words, + }, "RAK_4631_repeater") + for capacity in (0, 256, 511, 2049): + with self.subTest(tx_capacity=capacity), self.assertRaisesRegex(ValueError, "512..2048 bytes"): + ram.requirements("NRF52_PLATFORM", { + **defines, "ETHERNET_CLI_TX_BUFFER_BYTES": capacity, + }, "RAK_4631_repeater") + def test_published_image_tables_match_elf_and_use_its_own_reservations(self): with tempfile.TemporaryDirectory() as temp: path = Path(temp) / "reference.elf" @@ -225,7 +322,20 @@ class FirmwareRamTest(unittest.TestCase): def test_lazy_manual_stage_allocation_failure_and_clear(self): source = (ROOT / "src/helpers/ota/OtaContext.h").read_text() begin = source.index("#if defined(ESP32_PLATFORM) || (defined(NRF52_PLATFORM) && !defined(OTA_SEEDER_ONLY))") - end = source.index("#endif", begin) + len("#endif") + # The actual buffer methods contain their own platform/owner guards. + # Compile the complete outer branch rather than its first nested #if. + end, depth = begin, 0 + for line in source[begin:].splitlines(keepends=True): + directive = line.lstrip() + if directive.startswith("#if"): + depth += 1 + elif directive.startswith("#endif"): + depth -= 1 + end += len(line) + if depth == 0: + break + else: + self.fail("unterminated manual-stage buffer platform branch") branch = source[begin:end] code = r''' #include diff --git a/test/test_rak4631_ethernet_runtime.py b/test/test_rak4631_ethernet_runtime.py new file mode 100644 index 00000000..dbcd88d3 --- /dev/null +++ b/test/test_rak4631_ethernet_runtime.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Exercise combined Ethernet on/off, SPI ownership and retained Stream safety.""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +from test_serial_wifi_sessions import SANITIZER_FLAGS + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "test/fixtures/rak4631_ethernet_runtime" +SCENARIOS = ( + "disabled boot and exact command parsing", + "flash conflict or active OTA refuses initialization", + "task allocation failure releases SPI", + "OFF before worker scheduling avoids hardware IO", + "OFF during slow initial DHCP waits for cooperative cleanup", + "OFF wakes DHCP retry promptly", + "missing Ethernet hardware reaps worker before returning SPI", + "online framing, short writes and real remote disconnect", + "slow DHCP renewal gates retained Stream without blocking mesh", + "renewal failure returns to service without restarting worker", + "active or staged OTA refuses OFF", + "remote OFF reply precedes socket cleanup and synchronous task reclamation", + "OTA race retains SPI owner while main-thread release retries", + "optional setting-save failure is transactional", + "repeated hot switches reclaim workers without stale notifications", + "saved-ON boot does not save twice", + "OFF during slow renewal never releases SPI early", + "bounded ring queue retains order and prevents command dispatch under backpressure", + "oversized line discards the whole command prefix", + "embedded NUL discards the whole record", + "missing TCP acknowledgement holds the SPI gate without blocking mesh", + "stuck SEND command faults without vendor RX or accept", + "remote OFF acknowledgement is queued and missing ACK shutdown is bounded", + "fragmented oversized record discard is bounded between loop turns", + "newly stuck RECV command yields without a vendor command spin", + "delayed terminal RECV retains its synchronous response", + "new peer cannot receive previous peer synchronous or retained async output", + "partial record from a replaced peer cannot concatenate with new input", + "concurrent worker replacement cannot steal a record's response owner", +) + + +class Rak4631EthernetRuntimeTests(unittest.TestCase): + def test_production_runtime_with_background_dhcp_and_spi_gate(self): + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + if compiler is None: + self.skipTest("a host C++17 compiler is required") + with tempfile.TemporaryDirectory(prefix="meshcore-rak4631-ethernet-runtime-") as directory: + binary = Path(directory) / "ethernet-runtime" + compiled = subprocess.run( + [compiler, "-std=c++17", "-pthread", "-Wall", "-Wextra", "-Werror", + *SANITIZER_FLAGS, "-DETHERNET_ENABLED=1", "-DRAK4631_COMBINED_ETHERNET=1", + f"-I{FIXTURE / 'mocks'}", f"-I{ROOT / 'src'}", + str(FIXTURE / "test.cpp"), "-o", str(binary)], + capture_output=True, text=True, timeout=60, + ) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + for scenario, description in enumerate(SCENARIOS): + with self.subTest(scenario=description): + checked = subprocess.run([str(binary), str(scenario)], + capture_output=True, text=True, timeout=10) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + self.assertIn(f"PASS: RAK4631 Ethernet runtime scenario {scenario}", checked.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rak_combined_build_config.py b/test/test_rak_combined_build_config.py new file mode 100644 index 00000000..a2303e96 --- /dev/null +++ b/test/test_rak_combined_build_config.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Keep experimental RAK configs matched and separate from release recipes.""" + +import configparser +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import shlex +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts/generate_rak4631_combined_test.py" +SPEC = importlib.util.spec_from_file_location("rak_combined_config", SCRIPT) +generator = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(generator) + + +def ini(path): + result = configparser.ConfigParser(interpolation=None) + result.read(path) + return result + + +class RakCombinedBuildConfigTest(unittest.TestCase): + def test_matching_profiles_preserve_identity_and_never_run_platformio(self): + original = (ROOT / "platformio.ini").read_bytes() + variant = (ROOT / "variants/rak4631/platformio.ini").read_bytes() + with tempfile.TemporaryDirectory(prefix="meshcore-rak-config-") as temp: + directory = Path(temp) + marker = directory / "unexpected-platformio" + fake_pio = directory / "pio" + fake_pio.write_text("#!/bin/sh\ntouch " + shlex.quote(str(marker)) + "\nexit 91\n") + fake_pio.chmod(0o755) + output = directory / "generated configs" + result = subprocess.run(["python3", str(SCRIPT), "--output", str(output)], + cwd=ROOT, text=True, capture_output=True, + env={**os.environ, "PATH": str(directory) + os.pathsep + os.environ["PATH"]}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(marker.exists(), "config generation invoked PlatformIO") + receipt = json.loads((output / "profile-flags.json").read_text()) + self.assertEqual(receipt["loop_stack_bytes"], 8192) + self.assertEqual(receipt["full_rule_capacity"], 63) + self.assertEqual(receipt["mota_target_id"], "0x05f5ffae") + self.assertEqual(len(result.stdout.splitlines()), 4) + for item in receipt["configs"]: + name = item["profile"] + config_path = Path(item["config"]) + config = ini(config_path) + overlay_path = output / ("overlay-" + name + ".ini") + overlay = ini(overlay_path)["env:" + receipt["target"]] + flags = overlay["build_flags"] + self.assertIn("-DMOTA_TARGET_ID=0x05f5ffae", flags) + self.assertIn("-D MAX_NEIGHBOURS=50", flags) + self.assertNotIn("FLOOD_PACKET_FILTER_SLOTS", flags) + self.assertIn("-Ofast", overlay["build_unflags"]) + self.assertIn("-Os", flags) + self.assertIn("-DLORA_FREQ=910.525", flags) + self.assertIn(str(overlay_path), config["platformio"]["extra_configs"]) + self.assertEqual(hashlib.sha256(config_path.read_bytes()).hexdigest(), item["config_sha256"]) + combined = name.startswith("combined") + self.assertEqual("-DRAK4631_COMBINED_ETHERNET=1" in flags, combined) + self.assertEqual("-DETHERNET_ENABLED=1" in flags, combined) + self.assertEqual("lib_deps" in overlay, combined) + if combined: + self.assertIn(generator.ETHERNET_LIBRARY, overlay["lib_deps"]) + reduced = name.endswith("reduced") + self.assertEqual("-UENV_INCLUDE_BME680_BSEC" in flags, reduced) + if reduced: + self.assertIn("-DENV_INCLUDE_INA219=1", flags) + self.assertIn("${rak4631.build_flags}", flags) + self.assertNotIn("-UENV_INCLUDE_GPS", flags) + self.assertNotIn("ENV_INCLUDE_GPS", overlay["build_unflags"]) + # GPS comes from the inherited full sensor base; the + # reduction never removes or overrides that driver. + self.assertIn("-D ENV_INCLUDE_GPS=1", ini(ROOT / "platformio.ini")["sensor_base"]["build_flags"]) + self.assertEqual((ROOT / "platformio.ini").read_bytes(), original) + self.assertEqual((ROOT / "variants/rak4631/platformio.ini").read_bytes(), variant) + + def test_rejects_release_output_and_invalid_version_before_writing(self): + with self.assertRaisesRegex(ValueError, "outside the repository"): + generator.generate(ROOT / "out" / "unexpected-trial", "v1.17.1.9-test") + with tempfile.TemporaryDirectory() as temp: + output = Path(temp) / "configs" + for version in ("", "v1\n-DOTHER=1", 'v1"bad', "v1;bad", "v1$(bad)"): + with self.subTest(version=version), self.assertRaisesRegex(ValueError, "firmware version"): + generator.generate(output, version) + self.assertFalse(output.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rak_combined_ota.py b/test/test_rak_combined_ota.py new file mode 100644 index 00000000..469dfb4f --- /dev/null +++ b/test/test_rak_combined_ota.py @@ -0,0 +1,508 @@ +"""Exercise combined RAK4631 SPI ownership and the actual adaptive store.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def run_cpp(source, extra_sources=()): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + sources = [] + for index, text in enumerate((source, *extra_sources)): + cpp = path / f"test{index}.cpp" + cpp.write_text(text) + sources.append(str(cpp)) + exe = path / "test" + subprocess.run([ + "c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-DRAK4631_COMBINED_ETHERNET", "-DOTA_RAK_AUTO_STORE", + "-DOTA_INTERNAL_BOOTLOADER_UPDATE", "-DMESHCORE_OTA_DEVICE_DEFLATE=1", + "-I", str(ROOT), + *sources, "-o", str(exe), + ], check=True) + subprocess.run([str(exe)], check=True) + + +class CombinedRakOtaTest(unittest.TestCase): + def test_self_serve_and_manual_buffer_cannot_allocate_during_ethernet(self): + context = (ROOT / "src/helpers/ota/OtaContext.h").read_text() + start = context.index(" bool ensureServeBuffer() {") + end = context.index(" void releaseServeBuffer()", start) + buffer_method = context[start:end] + self_source = (ROOT / "src/helpers/ota/OtaSelf.cpp").read_text() + start = self_source.index("bool ota_serve_self(OtaContext& c, uint32_t fw_version) {") + end = self_source.index(" SelfFwInfo fi;", start) + self_prologue = self_source[start:end] + source = r''' +#include +#include +#include "src/helpers/nrf52/Rak4631SharedSpi.h" +using namespace mesh::ota; +namespace mesh { namespace ota { +static unsigned allocations = 0, self_body_calls = 0; +constexpr unsigned OTA_SERVE_BUF_SIZE = 4096; +void* tracked_malloc(size_t bytes) { + assert(!rak4631_ethernet_owns_spi()); ++allocations; + return std::malloc(bytes); +} +struct OtaContext { + uint8_t* serve_buf = nullptr; +#define malloc tracked_malloc +''' + buffer_method + r''' +#undef malloc +}; +''' + self_prologue + r''' + (void)c; (void)fw_version; + ++self_body_calls; + return true; +} +} } +int main() { + OtaContext context; + rak4631_set_ethernet_spi_owner(true); + assert(!context.ensureServeBuffer() && !ota_serve_self(context, 0)); + assert(!context.serve_buf && allocations == 0 && self_body_calls == 0); + rak4631_set_ethernet_spi_owner(false); + assert(context.ensureServeBuffer() && ota_serve_self(context, 0)); + assert(context.serve_buf && allocations == 1 && self_body_calls == 1); + free(context.serve_buf); +} +''' + run_cpp(source) + + def test_context_handoff_frees_idle_self_serve_and_preserves_busy_ota(self): + header = (ROOT / "src/helpers/ota/OtaContext.h").read_text() + start = header.index(" // Only the main thread may change ownership.") + end = header.index("#endif\n\n#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)", start) + body = header[start:end] + source = r''' +#include +#include +#include +#include +#include "src/helpers/ota/OtaBlInfo.h" +#include "src/helpers/ota/OtaRakStoragePolicy.h" +#include "src/helpers/nrf52/Rak4631SharedSpi.h" +using namespace mesh::ota; +namespace mesh { namespace ota { +static unsigned frees = 0, probes = 0; +static uint8_t detection = 2; +static uint32_t rak_id = 0; +static OtaBlCaps app_caps, boot_caps; +struct OtaBootloaderIdentity { + bool present = true, crc_ok = true; + const char* device_name = "4631_DFU"; +}; +static OtaBootloaderIdentity identity; +void ota_transport_deflate() {} +bool ota_self_serve_supported(bool external) { return external; } +constexpr unsigned CODEC_DETOOLS_INPLACE = 2; +class OtaStoreQspiNrf52 { +public: + static uint8_t autoDetect() { assert(!rak4631_ethernet_owns_spi()); ++probes; return detection; } + static void autoProbeIds(uint32_t& rak, uint32_t& w25) { rak = rak_id; w25 = 0xef4015; } +}; +struct OtaManager { + enum FetchState { IDLE, WANT_MANIFEST, WANT_LEAVES, VERIFYING_STAGED, FETCHING, + COMPLETE, FAILED, PAUSED }; + unsigned state = IDLE, serve_jobs = 0, manifest_jobs = 0, served = 0; + bool own = false, accept_full = true, accept_boot = false, archive = true; + void (*encoder)() = ota_transport_deflate; + bool servingPrimaryManifest(const uint8_t*) const { return own; } + unsigned fetchState() const { return state; } + unsigned pendingServeJobs() const { return serve_jobs; } + unsigned pendingManifestJobs() const { return manifest_jobs; } + unsigned servedCount() const { return served; } + void set_accept_full(bool value) { accept_full = value; } + void set_accept_bootloader(bool value) { accept_boot = value; } + void set_apply_codec(unsigned) {} + void set_fetch_store(void*) {} + void set_archive_interest(bool value) { archive = value; } + void set_transport_deflate_encoder(void (*value)()) { encoder = value; } + void clear_primary() { own = false; served = 0; } +}; +struct Store { + unsigned size = 0, resets = 0; + bool allow_reset = true, allow_internal = true; + unsigned staged_size() const { return size; } + bool resetSelection() { if (size || !allow_reset) return false; ++resets; return true; } + bool usesExternal() const { return !rak4631_ethernet_owns_spi() && detection == 2; } + bool usesInternal() const { return allow_internal && rak4631_ethernet_owns_spi(); } +}; +void tracked_free(void* pointer) { + if (pointer) { assert(!rak4631_ethernet_owns_spi()); ++frees; } + std::free(pointer); +} +struct Context { + OtaManager manager; + Store fetch_store; + uint8_t serve_self_manifest[1] = {}; + bool apply_pending = false, bootloader_apply_pending = false, serving = false; + bool folder_active = false, fetch_to_folder = false, self_serve_supported = true; + unsigned serve_expected = 0; + void* _folder_source = nullptr; + void* folder_dest = nullptr; + void* disconnected_folder_dest = nullptr; + uint8_t *serve_self_leaves = nullptr, *serve_self_proof = nullptr, *serve_buf = nullptr; + OtaBlCaps& bootloaderAppCaps() { return app_caps; } + OtaBlCaps& bootloaderUpdateCaps() { return boot_caps; } + OtaBootloaderIdentity& bootloaderIdentity() { return identity; } + void releaseServeBuffer() { tracked_free(serve_buf); serve_buf = nullptr; } +#define free tracked_free +''' + body + r''' +#undef free +}; +} } +int main() { + app_caps.present = boot_caps.present = true; + app_caps.apply_abi = boot_caps.apply_abi = 3; + app_caps.codec_mask = boot_caps.codec_mask = 5; + app_caps.storage_flags = 0x1e; app_caps.optional_app_storage = 0x14; + boot_caps.storage_flags = 0x0a; + Context c; + char error[160]; + for (unsigned state = 1; state <= 7; ++state) { + c.manager.state = state; + assert(!c.prepareRakEthernet(error, sizeof(error))); + assert(!c.canReleaseRakEthernet() && !rak4631_ethernet_owns_spi()); + assert(probes == 0 && c.fetch_store.resets == 0); + } + c.manager.state = OtaManager::IDLE; + c.fetch_store.size = 100; + assert(!c.prepareRakEthernet(error, sizeof(error)) && probes == 0); + c.fetch_store.size = 0; + for (bool* flag : {&c.apply_pending, &c.bootloader_apply_pending, &c.serving, + &c.folder_active, &c.fetch_to_folder}) { + *flag = true; + assert(!c.prepareRakEthernet(error, sizeof(error)) && probes == 0); + *flag = false; + } + for (unsigned* count : {&c.manager.serve_jobs, &c.manager.manifest_jobs, + &c.manager.served, &c.serve_expected}) { + *count = 1; + assert(!c.prepareRakEthernet(error, sizeof(error)) && probes == 0); + *count = 0; + } + for (void** pointer : {&c._folder_source, &c.folder_dest, &c.disconnected_folder_dest}) { + *pointer = &c; + assert(!c.prepareRakEthernet(error, sizeof(error)) && probes == 0); + *pointer = nullptr; + } + detection = 1; rak_id = 0xc84015; + assert(!c.prepareRakEthernet(error, sizeof(error))); + assert(strstr(error, "RAK15001") && !rak4631_ethernet_owns_spi()); + detection = 3; + assert(!c.prepareRakEthernet(error, sizeof(error))); + detection = 2; rak_id = 0x123456; + assert(!c.prepareRakEthernet(error, sizeof(error))); + assert(strstr(error, "unknown SPI")); + rak_id = 0; + app_caps.optional_app_storage = 0; + identity.device_name = "4631_W25Q16_DFU"; + assert(!c.prepareRakEthernet(error, sizeof(error))); + assert(strstr(error, "bootloader cannot") && c.fetch_store.resets == 0); + app_caps.optional_app_storage = 0x14; identity.device_name = "4631_DFU"; + c.manager.own = true; c.manager.served = 1; c.serving = true; + c.serve_self_leaves = static_cast(malloc(64)); + c.serve_self_proof = static_cast(malloc(64)); + c.serve_buf = static_cast(malloc(64)); + assert(c.prepareRakEthernet(error, sizeof(error))); + assert(rak4631_ethernet_owns_spi() && c.fetch_store.resets == 1); + assert(frees == 3 && !c.serve_self_leaves && !c.serve_self_proof && !c.serve_buf); + assert(!c.serving && c.manager.served == 0 && !c.manager.own); + assert(!c.manager.accept_full && c.manager.accept_boot && !c.manager.archive); + assert(!c.self_serve_supported && !c.manager.encoder); + c.manager.state = OtaManager::FETCHING; + assert(!c.releaseRakEthernet() && rak4631_ethernet_owns_spi()); + assert(c.fetch_store.resets == 1 && !c.self_serve_supported); + c.manager.state = OtaManager::IDLE; c.fetch_store.size = 100; + assert(!c.releaseRakEthernet() && rak4631_ethernet_owns_spi()); + c.fetch_store.size = 0; + assert(c.releaseRakEthernet() && !rak4631_ethernet_owns_spi()); + assert(c.fetch_store.resets == 2 && c.manager.accept_full); + assert(c.self_serve_supported && c.manager.encoder == ota_transport_deflate); +} +''' + run_cpp("#include \n" + source) + + def test_real_encoder_cannot_allocate_while_ethernet_owns_spi(self): + source = r''' +#include +#include +#include +#include "src/helpers/ota/OtaDeflate.h" +#include "src/helpers/nrf52/Rak4631SharedSpi.h" +using namespace mesh::ota; +static unsigned allocations = 0; +extern "C" void* __real_calloc(size_t, size_t); +extern "C" void* __wrap_calloc(size_t n, size_t size) { + assert(!rak4631_ethernet_owns_spi()); ++allocations; + return __real_calloc(n, size); +} +int main() { + uint8_t input[256], encoded[256], output[256]; + memset(input, 'A', sizeof(input)); + uint16_t length = 123; + rak4631_set_ethernet_spi_owner(true); + assert(!ota_transport_deflate(nullptr, input, sizeof(input), encoded, sizeof(encoded), &length)); + assert(length == 0 && allocations == 0); + rak4631_set_ethernet_spi_owner(false); + assert(ota_transport_deflate(nullptr, input, sizeof(input), encoded, sizeof(encoded), &length)); + assert(allocations == 1 && length < sizeof(input)); + rak4631_set_ethernet_spi_owner(true); + uint16_t decoded = 0; + assert(ota_transport_inflate(nullptr, encoded, length, output, sizeof(output), &decoded)); + assert(decoded == sizeof(input) && memcmp(input, output, sizeof(input)) == 0); + assert(allocations == 1); // ordinary OTA reception stays available +} +''' + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + objects = [] + for relative in ("OtaTinf.c",): + obj = path / (Path(relative).stem + ".o") + subprocess.run([ + "cc", "-std=c99", "-DENABLE_OTA", "-DMESHCORE_OTA_DEVICE_DEFLATE=1", + "-c", str(ROOT / "src/helpers/ota" / relative), "-o", str(obj), + ], check=True) + objects.append(str(obj)) + cpp = path / "test.cpp" + cpp.write_text(source) + exe = path / "test" + subprocess.run([ + "c++", "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-DRAK4631_COMBINED_ETHERNET", "-DENABLE_OTA", + "-DMESHCORE_OTA_DEVICE_DEFLATE=1", "-I", str(ROOT), + str(cpp), str(ROOT / "src/helpers/ota/OtaDeflate.cpp"), + *objects, "-Wl,--wrap=calloc", "-o", str(exe), + ], check=True) + subprocess.run([str(exe)], check=True) + + def test_only_explicit_combined_build_can_relax_static_bus_exclusion(self): + base = ["NRF52_PLATFORM", "OTA_QSPI_STORE", "OTA_QSPI_SHARED_WISBLOCK_SPI"] + cases = [ + (base, True), + (base + ["ETHERNET_ENABLED"], False), + (base + ["ETHERNET_ENABLED", "RAK4631_COMBINED_ETHERNET"], False), + (base + ["ETHERNET_ENABLED", "RAK4631_COMBINED_ETHERNET", + "OTA_RAK_AUTO_STORE", "RAK_4631"], True), + ] + for defines, accepted in cases: + with self.subTest(defines=defines): + result = subprocess.run([ + "c++", "-std=c++17", "-E", "-x", "c++", "-", + "-I", str(ROOT), *("-D" + value for value in defines), + ], input='#include "src/helpers/ota/OtaStoreQspiNrf52.h"\n', + capture_output=True, text=True) + self.assertEqual(result.returncode == 0, accepted, result.stderr) + + def test_spi_owner_is_shared_between_translation_units(self): + run_cpp(r''' +#include +#include "src/helpers/nrf52/Rak4631SharedSpi.h" +void reserve_elsewhere(bool); +int main() { + assert(!mesh::ota::rak4631_ethernet_owns_spi()); + reserve_elsewhere(true); + assert(mesh::ota::rak4631_ethernet_owns_spi()); + reserve_elsewhere(false); + assert(!mesh::ota::rak4631_ethernet_owns_spi()); +} +''', (r''' +#include "src/helpers/nrf52/Rak4631SharedSpi.h" +void reserve_elsewhere(bool owner) { + mesh::ota::rak4631_set_ethernet_spi_owner(owner); +} +''',)) + + def test_actual_adaptive_store_respects_owner_and_bootloader(self): + header = (ROOT / "src/helpers/ota/OtaStoreAdaptiveNrf52.h").read_text() + body = header[header.index("class OtaStoreAdaptiveNrf52"): + header.index("} // namespace ota")] + source = r''' +#include +#include +#include +#include "src/helpers/ota/OtaStore.h" +#include "src/helpers/ota/OtaBlInfo.h" +#include "src/helpers/ota/OtaRakStoragePolicy.h" +#include "src/helpers/nrf52/Rak4631SharedSpi.h" +using namespace mesh::ota; +namespace mesh { namespace ota { +static unsigned detection = 2, probes = 0, internal_live = 0, external_live = 0; +static uint8_t storage_flags = 0x1e, optional_storage = 0x14; +static bool valid_identity = true; +static const char* device_name = "4631_DFU"; +struct OtaBootloaderIdentity { bool crc_ok = true; const char* device_name; }; +bool ota_installed_bootloader_identity(OtaBootloaderIdentity& identity) { + identity.device_name = device_name; return valid_identity; +} +struct MotaManifest { bool is_bootloader() const { return false; } }; +bool mota_parse_manifest(const uint8_t*, size_t, MotaManifest&) { return false; } +OtaBlCaps simulatedAppCaps() { + OtaBlCaps c; c.present = true; c.apply_abi = 3; c.codec_mask = 5; + c.storage_flags = storage_flags; c.optional_app_storage = optional_storage; + return c; +} +OtaBlCaps simulatedBootCaps() { + OtaBlCaps c = simulatedAppCaps(); c.storage_flags = 0x0a; return c; +} +class Store : public OtaStore { +public: + uint32_t size = 0; + bool begin(uint32_t n) override { size = n; return true; } + bool write(uint32_t, const uint8_t*, uint32_t) override { return true; } + bool read(uint32_t, uint8_t*, uint32_t) const override { return true; } + uint32_t capacity() const override { return 65536; } + uint32_t staged_size() const override { return size; } + void clear() override { size = 0; } +}; +class OtaStoreFlashNrf52 : public Store { +public: + OtaStoreFlashNrf52() { ++internal_live; assert(external_live == 0); } + ~OtaStoreFlashNrf52() { --internal_live; } +}; +class OtaStoreQspiNrf52 : public Store { +public: + OtaStoreQspiNrf52() { ++external_live; assert(internal_live == 0); } + ~OtaStoreQspiNrf52() { --external_live; } + static uint8_t autoDetect() { ++probes; return detection; } + static bool headerW25Detected() { return false; } + uint32_t jedec_id() const { return 0xef4015; } + uint8_t status1() const { return 0; } + const char* last_stage() const { return "idle"; } + const char* last_error() const { return ""; } +}; +#define ota_bootloader_app_caps simulatedAppCaps +#define ota_bootloader_update_caps simulatedBootCaps +''' + body + r''' +} } +int main() { + // Ethernet may not discover NOR, and selects the internal path only when + // the installed bootloader supports that actual source. + rak4631_set_ethernet_spi_owner(true); + { + OtaStoreAdaptiveNrf52 store; + assert(store.usesInternal() && !store.usesExternal()); + assert(probes == 0); + assert(strstr(store.selectionReason(), "Ethernet active")); + assert(store.plan_layout(true, 40960, 365, 40960, true)); + assert(store.usesInternal()); + assert(store.begin(41330)); + assert(!store.resetSelection()); // privileged staging is pinned too + } + assert(internal_live == 0 && external_live == 0); + storage_flags = 0x06; optional_storage = 0; + device_name = "4631_W25Q16_DFU"; + { + OtaStoreAdaptiveNrf52 store; + assert(!store.usesInternal() && !store.usesExternal()); + assert(store.capacity() == 0 && !store.begin(100)); + assert(strstr(store.selectionReason(), "bootloader lacks internal OTA")); + assert(probes == 0); + } + storage_flags = 0x1e; optional_storage = 0x14; + device_name = "4631_DFU"; valid_identity = false; + { + OtaStoreAdaptiveNrf52 store; + assert(!store.usesInternal()); // an unverified optional marker is insufficient + assert(!store.begin(100)); + assert(probes == 0); + } + valid_identity = true; + rak4631_set_ethernet_spi_owner(false); + { + OtaStoreAdaptiveNrf52 store; + assert(store.usesExternal() && probes == 1); + assert(store.begin(100)); + assert(!store.resetSelection()); // neither receive nor ready data is dropped + rak4631_set_ethernet_spi_owner(true); // defensive gate against a bad caller + uint8_t byte = 0; + assert(!store.begin(200) && !store.write(0, &byte, 1)); + assert(!store.read(0, &byte, 1) && store.capacity() == 0); + assert(!store.resetSelection()); // existing data remains pinned + rak4631_set_ethernet_spi_owner(false); + store.clear(); + assert(store.resetSelection()); + assert(internal_live == 0 && external_live == 0); + rak4631_set_ethernet_spi_owner(true); + assert(store.usesInternal() && probes == 1); + assert(store.resetSelection()); + rak4631_set_ethernet_spi_owner(false); + assert(store.usesExternal() && probes == 2); + } + assert(internal_live == 0 && external_live == 0); +} +''' + run_cpp(source) + + def test_actual_discovery_does_no_gpio_io_while_ethernet_owns_spi(self): + cpp = (ROOT / "src/helpers/ota/OtaStoreQspiNrf52.cpp").read_text() + body = cpp[cpp.index("uint8_t OtaStoreQspiNrf52::autoDetect()"): + cpp.index("OtaStoreQspiNrf52::OtaStoreQspiNrf52()")] + source = r''' +#include +#include +#include "src/helpers/nrf52/Rak4631SharedSpi.h" +using namespace mesh::ota; +namespace mesh { namespace ota { +static uint8_t auto_cs_pin = 0xff, auto_detection = 0xff; +static uint32_t auto_jedec_id = 0, auto_rak_probe_id = 0, auto_w25_probe_id = 0; +static bool auto_header_w25 = false; +static constexpr uint8_t HEADER_SCK_PIN = 16, HEADER_IO0_PIN = 17, HEADER_IO1_PIN = 15; +static unsigned gpio_calls = 0, nor_calls = 0; +static uint32_t rak_id = 0, w25_id = 0xef4015, header_id = 0; +uint8_t arduino_to_physical(uint32_t pin) { return pin; } +void nrf_gpio_pin_set(uint32_t) { ++gpio_calls; } +void nrf_gpio_cfg_output(uint32_t) { ++gpio_calls; } +void nrf_gpio_cfg_default(uint32_t) { ++gpio_calls; } +uint32_t probe_nor(uint8_t cs) { + ++nor_calls; + return cs == 26 ? rak_id : auto_header_w25 ? header_id : w25_id; +} +class OtaStoreQspiNrf52 { +public: + static uint8_t autoDetect(); + static bool headerW25Detected(); + static void autoProbeIds(uint32_t&, uint32_t&); +}; +#if defined(OTA_RAK_AUTO_STORE) +''' + body + r''' +} } +int main() { + uint32_t rak, w25; + rak4631_set_ethernet_spi_owner(true); + assert(OtaStoreQspiNrf52::autoDetect() == 0); + OtaStoreQspiNrf52::autoProbeIds(rak, w25); + assert(!OtaStoreQspiNrf52::headerW25Detected()); + assert(gpio_calls == 0 && nor_calls == 0 && auto_detection == 0xff); + rak4631_set_ethernet_spi_owner(false); + assert(OtaStoreQspiNrf52::autoDetect() == 2); + assert(nor_calls == 2 && auto_cs_pin == 31 && auto_jedec_id == 0xef4015); + const unsigned gpio_after_discovery = gpio_calls; + rak4631_set_ethernet_spi_owner(true); + assert(OtaStoreQspiNrf52::autoDetect() == 0); + OtaStoreQspiNrf52::autoProbeIds(rak, w25); + assert(rak == 0 && w25 == 0xef4015); // diagnostics retain physical discovery + assert(gpio_calls == gpio_after_discovery && nor_calls == 2); + rak4631_set_ethernet_spi_owner(false); + assert(OtaStoreQspiNrf52::autoDetect() == 2 && nor_calls == 2); + auto_detection = 0xff; w25_id = 0; header_id = 0xef4015; + assert(OtaStoreQspiNrf52::autoDetect() == 2); + assert(OtaStoreQspiNrf52::headerW25Detected()); + const unsigned after_header_probe = nor_calls; + rak4631_set_ethernet_spi_owner(true); + assert(OtaStoreQspiNrf52::headerW25Detected()); // GPS still reserves header UART1 + assert(nor_calls == after_header_probe); +} +''' + run_cpp(source) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_rak_ethernet_transmit.py b/test/test_rak_ethernet_transmit.py new file mode 100644 index 00000000..a2e91ac4 --- /dev/null +++ b/test/test_rak_ethernet_transmit.py @@ -0,0 +1,286 @@ +"""Run the bounded TCP sender against a register-level W5100S simulator.""" + +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +DRIVER = (ROOT / ".pio/libdeps/RAK_4631_repeater_unified_lora_ota" + / "RAK13800-W5100S/src/w5100.h") + +ARDUINO = r''' +#pragma once +#include +constexpr unsigned OUTPUT = 1, LOW = 0, HIGH = 1; +inline void pinMode(unsigned, unsigned) {} +inline void digitalWrite(unsigned, unsigned) {} +''' +SPI = r''' +#pragma once +#include +constexpr unsigned MSBFIRST = 1, SPI_MODE0 = 0; +struct SPISettings { SPISettings(unsigned, unsigned, unsigned) {} }; +struct SPIClass { + bool active = false; + unsigned begins = 0, ends = 0; + void beginTransaction(SPISettings) { assert(!active); active = true; ++begins; } + void endTransaction() { assert(active); active = false; ++ends; } +}; +''' +RAK_HEADER = r''' +#pragma once +#define _RAK13800_W5100S_H_ +#define MAX_SOCK_NUM 8 +#include +#include +''' +# Keep the production register interface available in native CI without +# downloading board libraries. A second test uses the actual installed vendor +# header when available, and the firmware build always checks that interface. +PORTABLE_DRIVER = r''' +#pragma once +#include +#include +#define SPI_ETHERNET_SETTINGS SPISettings(14000000, MSBFIRST, SPI_MODE0) +struct SnSR { static constexpr uint8_t CLOSED=0, ESTABLISHED=0x17, CLOSE_WAIT=0x1c; }; +struct SnIR { static constexpr uint8_t SEND_OK=0x10, TIMEOUT=0x08; }; +enum SockCMD { Sock_CLOSE=0x10, Sock_SEND=0x20, Sock_RECV=0x40 }; +class W5100Class { + static uint8_t chip, CH_BASE_MSB, ss_pin; + static SPIClass* spi; +public: + static constexpr uint16_t SSIZE=2048, SMASK=2047; + static uint8_t init(); + static uint8_t getChip() { return chip; } + static SPIClass* getSPI() { return spi; } + static void setSPI(SPIClass& value) { spi=&value; } + static uint16_t SBASE(uint8_t socket) { return 0x4000 + socket*SSIZE; } + static uint16_t RBASE(uint8_t socket) { return 0x6000 + socket*SSIZE; } + static uint16_t read(uint16_t, uint8_t*, uint16_t); + static uint16_t write(uint16_t, const uint8_t*, uint16_t); + static uint8_t read(uint16_t address) { uint8_t value; read(address,&value,1); return value; } + static uint8_t write(uint16_t address,uint8_t value) { return write(address,&value,1); } + static uint16_t reg(uint8_t socket,uint16_t offset) { return (CH_BASE_MSB<<8)+socket*256+offset; } + static uint8_t readSnSR(uint8_t socket) { return read(reg(socket,3)); } + static uint8_t readSnIR(uint8_t socket) { return read(reg(socket,2)); } + static uint8_t readSnCR(uint8_t socket) { return read(reg(socket,1)); } + static void writeSnIR(uint8_t socket,uint8_t value) { write(reg(socket,2),value); } + static void writeSnCR(uint8_t socket,uint8_t value) { write(reg(socket,1),value); } + static uint16_t readSnTX_WR(uint8_t socket) { + uint8_t bytes[2]; read(reg(socket,0x24),bytes,2); return (bytes[0]<<8)|bytes[1]; + } + static uint16_t readSnTX_FSR(uint8_t socket) { + uint8_t bytes[2]; read(reg(socket,0x20),bytes,2); return (bytes[0]<<8)|bytes[1]; + } + static uint16_t readSnRX_RSR(uint8_t socket) { + uint8_t bytes[2]; read(reg(socket,0x26),bytes,2); return (bytes[0]<<8)|bytes[1]; + } + static uint16_t readSnRX_RD(uint8_t socket) { + uint8_t bytes[2]; read(reg(socket,0x28),bytes,2); return (bytes[0]<<8)|bytes[1]; + } + static void writeSnRX_RD(uint8_t socket,uint16_t value) { + uint8_t bytes[2]={uint8_t(value>>8),uint8_t(value)}; write(reg(socket,0x28),bytes,2); + } + static void writeSnTX_WR(uint8_t socket,uint16_t value) { + uint8_t bytes[2]={uint8_t(value>>8),uint8_t(value)}; write(reg(socket,0x24),bytes,2); + } + static void execCmdSn(uint8_t, SockCMD); // deliberately undefined: a blocking call fails linking +}; +extern W5100Class W5100; +''' + +RUNNER = r''' +#include +#include +#include +#include "src/helpers/nrf52/EthernetCliTransmit.h" +using mesh::nrf52::EthernetCliTransmit; +static_assert(Sock_SEND == 0x20 && Sock_CLOSE == 0x10 && Sock_RECV == 0x40); +static_assert(SnIR::SEND_OK == 0x10 && SnIR::TIMEOUT == 0x08); +static_assert(SnSR::ESTABLISHED == 0x17 && SnSR::CLOSE_WAIT == 0x1c); +static SPIClass spi; +uint8_t W5100Class::chip = 51, W5100Class::CH_BASE_MSB = 4, W5100Class::ss_pin = 26; +SPIClass* W5100Class::spi = &::spi; +W5100Class W5100; +static uint8_t memory[65536] = {}; +static uint8_t next_chip = 51; +static unsigned reads = 0, writes = 0, free_reads = 0, data_writes = 0, commands = 0; +static bool short_write = false; +static std::vector changing_free; +static std::vector changing_receive; +uint8_t W5100Class::init() { chip = next_chip; spi = &::spi; return chip; } +uint16_t W5100Class::read(uint16_t address, uint8_t* buffer, uint16_t length) { + assert(::spi.active); ++reads; + if (address >= 0x400 && address < 0x800 && (address&255) == 0x20 && length == 2) { + ++free_reads; + if (!changing_free.empty()) { + const uint16_t value = changing_free.front(); changing_free.erase(changing_free.begin()); + buffer[0] = value>>8; buffer[1] = value; return length; + } + } + if (address >= 0x400 && address < 0x800 && (address&255) == 0x26 && length == 2 && + !changing_receive.empty()) { + const uint16_t value=changing_receive.front(); changing_receive.erase(changing_receive.begin()); + buffer[0]=value>>8; buffer[1]=value; return length; + } + memcpy(buffer,memory+address,length); + return length; +} +uint16_t W5100Class::write(uint16_t address,const uint8_t* buffer,uint16_t length) { + assert(::spi.active); ++writes; + if (address >= 0x4000) { + ++data_writes; + if (short_write) return 0; + } + if (address >= 0x400 && address < 0x800 && length == 1 && (address&255) == 2) { + memory[address] &= ~buffer[0]; + } else { + if (address >= 0x400 && address < 0x800 && length == 1 && (address&255) == 1) ++commands; + memcpy(memory+address,buffer,length); + } + return length; +} +static uint16_t reg(unsigned offset) { return 0x400+offset; } +static void put16(unsigned offset,uint16_t value) { + memory[reg(offset)] = value>>8; memory[reg(offset)+1] = value; +} +static uint16_t get16(unsigned offset) { return (memory[reg(offset)]<<8)|memory[reg(offset)+1]; } +static void reset() { + memset(memory,0,sizeof(memory)); + memory[reg(3)] = SnSR::ESTABLISHED; + put16(0x20,2048); + reads = writes = free_reads = data_writes = commands = 0; + changing_free.clear(); changing_receive.clear(); short_write = false; next_chip = 51; + W5100.init(); + assert(!::spi.active && ::spi.begins == ::spi.ends); +} +int main() { + uint8_t payload[512]; + for (unsigned i=0;i