From 8c1103d4a5f53499b55a6ac2ab05ecdf859593df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1ez?= Date: Sat, 28 Feb 2026 13:01:56 +0100 Subject: [PATCH 01/11] feat(build): support example-targeted builds and packet codegen fixes --- CMakeLists.txt | 14 +- .../Packet_generation/Packet_descriptions.py | 9 +- Core/Src/main.cpp | 3 +- tools/build-example.sh | 269 ++++++++++++++++++ 4 files changed, 288 insertions(+), 7 deletions(-) create mode 100755 tools/build-example.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index 04a60269..00c73cbc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,7 +5,7 @@ include(CTest) enable_testing() set(EXECUTABLE ${PROJECT_NAME}.elf) -set(BOARD_NAME "VCU" CACHE STRING "Board key from Core/Inc/Code_generation/JSON_ADE/boards.json" FORCE) +set(BOARD_NAME "TEST" CACHE STRING "Board key from Core/Inc/Code_generation/JSON_ADE/boards.json") if(BOARD_NAME STREQUAL "") message(FATAL_ERROR "BOARD_NAME cannot be empty") endif() @@ -73,6 +73,7 @@ add_custom_command( DEPENDS ${GENERATOR_SCRIPT} ${CMAKE_SOURCE_DIR}/Core/Inc/Code_generation/Packet_generation/Packet_generation.py + ${CMAKE_SOURCE_DIR}/Core/Inc/Code_generation/Packet_generation/Packet_descriptions.py ${CMAKE_SOURCE_DIR}/Core/Inc/Code_generation/Packet_generation/DataTemplate.hpp ${CMAKE_SOURCE_DIR}/Core/Inc/Code_generation/Packet_generation/OrderTemplate.hpp ${GENERATOR_JSONS} @@ -125,6 +126,16 @@ if(CMAKE_CROSSCOMPILING) list(REMOVE_ITEM SOURCE_CPP ${EXAMPLE_CPP}) endif() + set(EXAMPLE_SELECTED OFF) + # If an EXAMPLE_* macro is injected through CXX flags, mark the build as + # example-selected so main.cpp can disable only the default main(). + if(BUILD_EXAMPLES) + if(CMAKE_CXX_FLAGS MATCHES "(^|[ \t])-DEXAMPLE_[A-Za-z0-9_]+") + set(EXAMPLE_SELECTED ON) + message(STATUS "Template project: EXAMPLE_* detected, disabling default main()") + endif() + endif() + add_executable(${EXECUTABLE} ${SOURCE_C} ${SOURCE_CPP} @@ -146,6 +157,7 @@ if(CMAKE_CROSSCOMPILING) ) target_compile_definitions(${EXECUTABLE} PRIVATE + $<$:EXAMPLE_SELECTED> $<$:STLIB_ETH> $,NUCLEO,BOARD> $,HSE_VALUE=8000000,HSE_VALUE=25000000> diff --git a/Core/Inc/Code_generation/Packet_generation/Packet_descriptions.py b/Core/Inc/Code_generation/Packet_generation/Packet_descriptions.py index 2b429415..9811d610 100644 --- a/Core/Inc/Code_generation/Packet_generation/Packet_descriptions.py +++ b/Core/Inc/Code_generation/Packet_generation/Packet_descriptions.py @@ -147,7 +147,7 @@ def __init__(self,measurements:list, variable:str, filename:str="Unknown"): raise Exception(f"Measurement not found for variable: {variable} in file: {filename}") self.name = measurement["name"] - self.type = (self._unsigned_int_correction(measurement["type"]).replace(" ", "_").replace("-", "_")) + self.type = (self._numeric_type_correction(measurement["type"]).replace(" ", "_").replace("-", "_")) if self.type == "enum": values = [] for value in measurement["enumValues"]: @@ -175,9 +175,10 @@ def _MeasurementSearch(measurements:list, variable:str): @staticmethod - def _unsigned_int_correction(type:str): - aux_type = type[:4] - if aux_type == "uint": + def _numeric_type_correction(type:str): + if type.startswith("uint") and not type.endswith("_t"): + type += "_t" + elif type.startswith("int") and not type.endswith("_t"): type += "_t" elif type == "float32": type = "float" diff --git a/Core/Src/main.cpp b/Core/Src/main.cpp index 55e9bc92..a5c64aab 100644 --- a/Core/Src/main.cpp +++ b/Core/Src/main.cpp @@ -8,8 +8,7 @@ constexpr auto led = ST_LIB::DigitalOutputDomain::DigitalOutput(ST_LIB::PF13); using MainBoard = ST_LIB::Board; -#if !defined(EXAMPLE_ADC) && !defined(EXAMPLE_ETHERNET) && !defined(EXAMPLE_MPU) && \ - !defined(EXAMPLE_HARDFAULT) && !defined(EXAMPLE_EXTI) +#ifndef EXAMPLE_SELECTED int main(void) { MainBoard::init(); diff --git a/tools/build-example.sh b/tools/build-example.sh new file mode 100755 index 00000000..9f2d771d --- /dev/null +++ b/tools/build-example.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: tools/build-example.sh --example [options] + +Compiles one example by injecting EXAMPLE_* / TEST_* defines through CMake. + +Options: + -l, --list List all available EXAMPLE_* and their TEST_* macros. + --list-tests List TEST_* macros for one example (e.g. fmac, EXAMPLE_FMAC). + -e, --example Example name (e.g. fmac, adc, EXAMPLE_FMAC). + -t, --test Test selector (default: TEST_0). Accepts 0, 1, TEST_1... + --no-test Do not define any TEST_* macro. + -p, --preset CMake configure/build preset + (default: board-debug-eth-ksz8041). + -b, --board-name Optional BOARD_NAME override (e.g. TEST). + --extra-cxx-flags Extra CXX flags appended after EXAMPLE/TEST defines. + -h, --help Show this help. + +Examples: + tools/build-example.sh --list + tools/build-example.sh --list-tests fmac + tools/build-example.sh --example fmac + tools/build-example.sh --example EXAMPLE_ADC --test 0 --preset board-debug + tools/build-example.sh --example ethernet --test TEST_0 --board-name TEST +EOF +} + +normalize_example_macro() { + local input="$1" + input="${input#EXAMPLE_}" + input="${input#example_}" + input="$(printf '%s' "$input" | tr '[:lower:]-' '[:upper:]_')" + printf 'EXAMPLE_%s' "$input" +} + +normalize_test_macro() { + local input="$1" + input="${input#TEST_}" + input="${input#test_}" + if [[ "$input" =~ ^[0-9]+$ ]]; then + printf 'TEST_%s' "$input" + return + fi + input="$(printf '%s' "$input" | tr '[:lower:]-' '[:upper:]_')" + printf 'TEST_%s' "$input" +} + +collect_examples() { + grep -Rho "EXAMPLE_[A-Z0-9_]\+" "${repo_root}/Core/Src/Examples"/*.cpp 2>/dev/null | sort -u +} + +find_example_file() { + local example_macro="$1" + local file + for file in "${repo_root}/Core/Src/Examples"/*.cpp; do + [[ -f "$file" ]] || continue + if grep -Eq "^[[:space:]]*#(if|ifdef|elif)[[:space:]].*\\b${example_macro}\\b" "$file"; then + printf '%s\n' "$file" + return 0 + fi + done + return 1 +} + +collect_tests_for_example() { + local example_macro="$1" + local file + file="$(find_example_file "$example_macro" || true)" + if [[ -z "$file" ]]; then + return 1 + fi + + perl -nle 'while(/\b(TEST_[A-Z0-9_]+)\b/g){print $1}' "$file" 2>/dev/null | sort -u || true +} + +print_examples_table() { + local file + local example_macro + local tests_csv + local tests_raw + local rel_file + + while IFS='|' read -r macro tests file_path; do + printf "%-40s tests: %s\n" "$macro" "$tests" + printf " file: %s\n" "$file_path" + done < <( + for file in "${repo_root}/Core/Src/Examples"/*.cpp; do + [[ -f "$file" ]] || continue + example_macro="$(grep -Eho "EXAMPLE_[A-Z0-9_]+" "$file" | head -n1 || true)" + [[ -z "$example_macro" ]] && continue + + tests_raw="$(perl -nle 'while(/\b(TEST_[A-Z0-9_]+)\b/g){print $1}' "$file" 2>/dev/null || true)" + if [[ -n "$tests_raw" ]]; then + tests_csv="$(printf '%s\n' "$tests_raw" | sort -u | paste -sd',' -)" + else + tests_csv="" + fi + + rel_file="${file#${repo_root}/}" + printf "%s|%s|%s\n" "$example_macro" "$tests_csv" "$rel_file" + done | sort + ) +} + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +repo_root="$(CDPATH= cd -- "${script_dir}/.." && pwd)" + +list_mode=0 +list_tests_target="" +example_name="" +test_macro="TEST_0" +test_explicit=0 +preset="board-debug-eth-ksz8041" +board_name="" +extra_cxx_flags="" + +while [[ $# -gt 0 ]]; do + case "$1" in + -l|--list) + list_mode=1 + shift + ;; + --list-tests) + list_tests_target="${2:-}" + shift 2 + ;; + -e|--example) + example_name="${2:-}" + shift 2 + ;; + -t|--test) + test_macro="$(normalize_test_macro "${2:-}")" + test_explicit=1 + shift 2 + ;; + --no-test) + test_macro="" + test_explicit=1 + shift + ;; + -p|--preset) + preset="${2:-}" + shift 2 + ;; + -b|--board-name) + board_name="${2:-}" + shift 2 + ;; + --extra-cxx-flags) + extra_cxx_flags="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + if [[ -z "$example_name" ]]; then + example_name="$1" + shift + else + echo "Unknown argument: $1" >&2 + usage + exit 1 + fi + ;; + esac +done + +if [[ "$list_mode" -eq 1 ]]; then + print_examples_table + exit 0 +fi + +if [[ -n "$list_tests_target" ]]; then + example_macro="$(normalize_example_macro "$list_tests_target")" + if ! find_example_file "$example_macro" >/dev/null; then + echo "Unknown example macro '${example_macro}'." >&2 + exit 1 + fi + tests_output="$(collect_tests_for_example "$example_macro" || true)" + + echo "${example_macro}" + if [[ -z "$tests_output" ]]; then + echo " - " + exit 0 + fi + + while IFS= read -r test_name; do + [[ -n "$test_name" ]] && echo " - ${test_name}" + done <&2 + usage + exit 1 +fi + +example_macro="$(normalize_example_macro "$example_name")" + +available_macros=() +while IFS= read -r macro; do + available_macros+=("$macro") +done < <(collect_examples) + +if [[ "${#available_macros[@]}" -gt 0 ]]; then + found=0 + for macro in "${available_macros[@]}"; do + if [[ "$macro" == "$example_macro" ]]; then + found=1 + break + fi + done + if [[ "$found" -ne 1 ]]; then + echo "Unknown example macro '${example_macro}'." >&2 + echo "Available examples:" >&2 + printf ' - %s\n' "${available_macros[@]}" >&2 + exit 1 + fi +fi + +if [[ "$test_explicit" -ne 1 ]]; then + tests_output="$(collect_tests_for_example "$example_macro" || true)" + if [[ -z "$tests_output" ]]; then + test_macro="" + fi +fi + +define_flags="-D${example_macro}" +if [[ -n "$test_macro" ]]; then + define_flags+=" -D${test_macro}" +fi +if [[ -n "$extra_cxx_flags" ]]; then + define_flags+=" ${extra_cxx_flags}" +fi + +echo "[build-example] repo: ${repo_root}" +echo "[build-example] preset: ${preset}" +echo "[build-example] example: ${example_macro}" +if [[ -n "$test_macro" ]]; then + echo "[build-example] test: ${test_macro}" +else + echo "[build-example] test: " +fi + +cd "${repo_root}" + +configure_cmd=( + cmake + --preset "${preset}" + -DBUILD_EXAMPLES=ON + "-DCMAKE_CXX_FLAGS=${define_flags}" +) + +if [[ -n "$board_name" ]]; then + configure_cmd+=("-DBOARD_NAME=${board_name}") +fi + +"${configure_cmd[@]}" +cmake --build --preset "${preset}" + +echo "[build-example] Build completed." From 5bb3d56d15f3c8aa32dff08ed1e674230fed7a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1ez?= Date: Sat, 28 Feb 2026 13:02:11 +0100 Subject: [PATCH 02/11] feat(tcpip): add hardware stress example and host test harness --- Core/Src/Examples/ExampleTCPIP.cpp | 736 +++++++++++ deps/ST-LIB | 2 +- tools/configure_nucleo_host_network_macos.sh | 178 +++ tools/example_tcpip_quality_gate.sh | 237 ++++ tools/example_tcpip_soak.sh | 157 +++ tools/example_tcpip_soak_hours.sh | 205 ++++ tools/example_tcpip_stress.py | 1141 ++++++++++++++++++ tools/run_example_tcpip_nucleo.sh | 213 ++++ tools/run_example_tcpip_stress.sh | 8 + 9 files changed, 2876 insertions(+), 1 deletion(-) create mode 100644 Core/Src/Examples/ExampleTCPIP.cpp create mode 100755 tools/configure_nucleo_host_network_macos.sh create mode 100755 tools/example_tcpip_quality_gate.sh create mode 100755 tools/example_tcpip_soak.sh create mode 100755 tools/example_tcpip_soak_hours.sh create mode 100755 tools/example_tcpip_stress.py create mode 100755 tools/run_example_tcpip_nucleo.sh create mode 100755 tools/run_example_tcpip_stress.sh diff --git a/Core/Src/Examples/ExampleTCPIP.cpp b/Core/Src/Examples/ExampleTCPIP.cpp new file mode 100644 index 00000000..a864f27f --- /dev/null +++ b/Core/Src/Examples/ExampleTCPIP.cpp @@ -0,0 +1,736 @@ +#ifdef EXAMPLE_TCPIP + +#include "main.h" +#include "ST-LIB.hpp" + +using namespace ST_LIB; + +#ifdef STLIB_ETH + +#ifndef TCPIP_TEST_BOARD_IP +#define TCPIP_TEST_BOARD_IP 192.168.1.7 +#endif + +#ifndef TCPIP_TEST_HOST_IP +#define TCPIP_TEST_HOST_IP 192.168.1.9 +#endif + +#define TCPIP_STRINGIFY_IMPL(value) #value +#define TCPIP_STRINGIFY(value) TCPIP_STRINGIFY_IMPL(value) + +#ifndef TCPIP_TEST_TCP_SERVER_PORT +#define TCPIP_TEST_TCP_SERVER_PORT 40000 +#endif + +#ifndef TCPIP_TEST_TCP_CLIENT_LOCAL_PORT +#define TCPIP_TEST_TCP_CLIENT_LOCAL_PORT 40001 +#endif + +#ifndef TCPIP_TEST_TCP_CLIENT_REMOTE_PORT +#define TCPIP_TEST_TCP_CLIENT_REMOTE_PORT 40002 +#endif + +#ifndef TCPIP_TEST_UDP_LOCAL_PORT +#define TCPIP_TEST_UDP_LOCAL_PORT 40003 +#endif + +#ifndef TCPIP_TEST_UDP_REMOTE_PORT +#define TCPIP_TEST_UDP_REMOTE_PORT 40004 +#endif + +constexpr auto led = ST_LIB::DigitalOutputDomain::DigitalOutput(ST_LIB::PB0); + +#if defined(USE_PHY_LAN8742) +constexpr auto eth = EthernetDomain::Ethernet( + EthernetDomain::PINSET_H10, + "00:80:e1:00:01:07", + TCPIP_STRINGIFY(TCPIP_TEST_BOARD_IP), + "255.255.0.0" +); +#elif defined(USE_PHY_LAN8700) +constexpr auto eth = EthernetDomain::Ethernet( + EthernetDomain::PINSET_H10, + "00:80:e1:00:01:07", + TCPIP_STRINGIFY(TCPIP_TEST_BOARD_IP), + "255.255.0.0" +); +#elif defined(USE_PHY_KSZ8041) +constexpr auto eth = EthernetDomain::Ethernet( + EthernetDomain::PINSET_H11, + "00:80:e1:00:01:07", + TCPIP_STRINGIFY(TCPIP_TEST_BOARD_IP), + "255.255.0.0" +); +#else +#error "No PHY selected for Ethernet pinset selection" +#endif + +using ExampleTCPIPBoard = ST_LIB::Board; + +namespace { + +constexpr uint16_t TCPIP_CMD_ORDER_ID = 0x7101; +constexpr uint16_t TCPIP_RESPONSE_ORDER_ID = 0x7102; +constexpr uint16_t TCPIP_PAYLOAD_ORDER_ID = 0x7103; +constexpr uint16_t TCPIP_CLIENT_STREAM_ORDER_ID = 0x7104; +constexpr uint16_t TCPIP_SERVER_STREAM_ORDER_ID = 0x7105; + +constexpr uint16_t TCPIP_UDP_PROBE_PACKET_ID = 0x7201; +constexpr uint16_t TCPIP_UDP_STATUS_PACKET_ID = 0x7202; + +constexpr uint32_t CMD_RESET = 1; +constexpr uint32_t CMD_PING = 2; +constexpr uint32_t CMD_GET_STATS = 3; +constexpr uint32_t CMD_FORCE_DISCONNECT = 4; +constexpr uint32_t CMD_BURST_SERVER = 5; +constexpr uint32_t CMD_BURST_CLIENT = 6; +constexpr uint32_t CMD_FORCE_CLIENT_RECONNECT = 7; +constexpr uint32_t CMD_GET_HEALTH = 8; +constexpr uint32_t CMD_RESET_HEALTH = 9; + +constexpr uint32_t STREAMS_PER_LOOP = 6; +constexpr uint32_t CLIENT_HEARTBEAT_MS = 200; +constexpr uint32_t CLIENT_RECONNECT_MS = 500; +constexpr uint32_t CLIENT_RECONNECT_RECREATE_EVERY = 60; +constexpr uint32_t STREAM_RETRY_BACKOFF_MS = 3; +constexpr uint32_t STREAM_SUCCESS_SPACING_MS = 1; +constexpr uint32_t STREAM_DISCONNECTED_BACKOFF_MS = 20; +constexpr uint32_t STREAM_FAIL_STREAK_BACKOFF_MS = 40; +constexpr uint32_t CLIENT_SEND_FAIL_RECREATE_THRESHOLD = 256; +constexpr uint32_t CLIENT_SEND_FAIL_RECREATE_MIN_INTERVAL_MS = 1000; +constexpr uint32_t LED_TOGGLE_MS = 250; +constexpr uint32_t HEALTH_PAGE_COUNT = 6; + +enum HealthReason : uint32_t { + HEALTH_REASON_NONE = 0, + HEALTH_REASON_BOOT = 1, + HEALTH_REASON_CMD_FORCE_DISCONNECT = 2, + HEALTH_REASON_SERVER_RECREATE = 3, + HEALTH_REASON_CLIENT_RECREATE_CMD = 4, + HEALTH_REASON_CLIENT_RECREATE_WATCHDOG = 5, + HEALTH_REASON_CLIENT_RECONNECT_POLL = 6, + HEALTH_REASON_CLIENT_SEND_FAIL = 7 +}; + +struct RuntimeStats { + uint32_t tcp_commands_rx = 0; + uint32_t tcp_payload_rx = 0; + uint32_t tcp_payload_bad = 0; + uint32_t tcp_responses_tx = 0; + uint32_t tcp_client_tx_ok = 0; + uint32_t tcp_client_tx_fail = 0; + uint32_t udp_probe_rx = 0; + uint32_t udp_probe_bad = 0; + uint32_t udp_status_tx_ok = 0; + uint32_t udp_status_tx_fail = 0; + uint32_t forced_disconnects = 0; +}; + +RuntimeStats stats{}; + +struct HealthTelemetry { + uint32_t loop_iterations = 0; + uint32_t last_cmd_opcode = 0; + uint32_t last_cmd_at_ms = 0; + + uint32_t reason_last = HEALTH_REASON_BOOT; + uint32_t reason_arg_last = 0; + uint32_t reason_update_count = 1; + + uint32_t tcp_server_recreate_count = 0; + uint32_t tcp_client_recreate_count = 0; + uint32_t tcp_client_reconnect_calls = 0; + uint32_t tcp_client_not_connected_ticks = 0; + + uint32_t tcp_client_send_fail_streak = 0; + uint32_t tcp_client_send_fail_streak_max = 0; + uint32_t tcp_client_send_fail_events = 0; + uint32_t tcp_client_send_ok_events = 0; + + uint32_t server_burst_requested_max = 0; + uint32_t client_burst_requested_max = 0; +}; + +HealthTelemetry health{}; + +Server* tcp_server = nullptr; +Socket* tcp_client = nullptr; +DatagramSocket* udp_socket = nullptr; + +uint32_t command_opcode = 0; +uint32_t command_arg0 = 0; +uint32_t command_arg1 = 0; +bool pending_command = false; +bool force_disconnect_requested = false; +uint32_t server_burst_remaining = 0; +uint32_t client_burst_remaining = 0; +uint32_t next_server_burst_attempt_ms = 0; +uint32_t next_client_burst_attempt_ms = 0; +bool tcp_response_retry_pending = false; +uint32_t pending_response_code = 0; +uint32_t pending_response_value0 = 0; +uint32_t pending_response_value1 = 0; +uint32_t pending_response_value2 = 0; +bool client_send_failed_last = false; + +uint32_t response_code = 0; +uint32_t response_value0 = 0; +uint32_t response_value1 = 0; +uint32_t response_value2 = 0; +StackOrder tcp_response_order( + TCPIP_RESPONSE_ORDER_ID, + &response_code, + &response_value0, + &response_value1, + &response_value2 +); + +uint32_t tcp_payload_sequence = 0; +uint32_t tcp_payload_checksum = 0; +array tcp_payload_bytes = {}; + +uint32_t tcp_client_stream_sequence = 0; +uint32_t tcp_client_stream_ok = 0; +uint32_t tcp_client_stream_fail = 0; +StackOrder tcp_client_stream_order( + TCPIP_CLIENT_STREAM_ORDER_ID, + &tcp_client_stream_sequence, + &tcp_client_stream_ok, + &tcp_client_stream_fail +); + +uint32_t tcp_server_stream_sequence = 0; +array tcp_server_stream_bytes = {}; +StackOrder tcp_server_stream_order( + TCPIP_SERVER_STREAM_ORDER_ID, + &tcp_server_stream_sequence, + &tcp_server_stream_bytes +); + +uint32_t udp_probe_sequence = 0; +uint32_t udp_probe_checksum = 0; +array udp_probe_bytes = {}; +StackPacket udp_probe_packet( + TCPIP_UDP_PROBE_PACKET_ID, + &udp_probe_sequence, + &udp_probe_checksum, + &udp_probe_bytes +); + +uint32_t udp_status_sequence = 0; +uint32_t udp_status_ok = 0; +uint32_t udp_status_bad = 0; +StackPacket udp_status_packet( + TCPIP_UDP_STATUS_PACKET_ID, + &udp_status_sequence, + &udp_status_ok, + &udp_status_bad +); + +uint32_t last_udp_probe_sequence = 0; +bool udp_probe_seen = false; + +uint32_t checksum32(const uint8_t* data, size_t size) { + uint32_t checksum = 2166136261u; + for (size_t i = 0; i < size; i++) { + checksum ^= data[i]; + checksum *= 16777619u; + } + return checksum; +} + +void fill_pattern(uint8_t* data, size_t size, uint32_t seed) { + for (size_t i = 0; i < size; i++) { + data[i] = static_cast((seed + (i * 17u)) & 0xFFu); + } +} + +bool try_send_tcp_response(uint32_t code, uint32_t value0, uint32_t value1, uint32_t value2) { + response_code = code; + response_value0 = value0; + response_value1 = value1; + response_value2 = value2; + bool client_sent = false; + bool server_sent = false; + const bool client_connected = (tcp_client != nullptr && tcp_client->is_connected()); + const bool server_has_connections = + (tcp_server != nullptr && tcp_server->connections_count() > 0); + + if (client_connected) { + client_sent = tcp_client->send_order(tcp_response_order); + } + if (tcp_server != nullptr) { + server_sent = tcp_server->broadcast_order(tcp_response_order); + } + + // Prefer ACKing through the server path whenever server connections exist, because + // command/control in stress mode runs over TCP server and must receive every response. + if (server_has_connections) { + return server_sent; + } + if (client_connected) { + return client_sent; + } + return false; +} + +void queue_tcp_response(uint32_t code, uint32_t value0, uint32_t value1, uint32_t value2) { + pending_response_code = code; + pending_response_value0 = value0; + pending_response_value1 = value1; + pending_response_value2 = value2; + tcp_response_retry_pending = true; +} + +void flush_pending_tcp_response() { + if (!tcp_response_retry_pending) { + return; + } + if (try_send_tcp_response( + pending_response_code, + pending_response_value0, + pending_response_value1, + pending_response_value2 + )) { + stats.tcp_responses_tx++; + tcp_response_retry_pending = false; + } +} + +void tcp_command_callback() { + stats.tcp_commands_rx++; + pending_command = true; +} + +void tcp_payload_callback() { + stats.tcp_payload_rx++; + if (checksum32(tcp_payload_bytes.data(), tcp_payload_bytes.size()) != tcp_payload_checksum) { + stats.tcp_payload_bad++; + } +} + +StackOrder tcp_command_order( + TCPIP_CMD_ORDER_ID, + &tcp_command_callback, + &command_opcode, + &command_arg0, + &command_arg1 +); + +StackOrder tcp_payload_order( + TCPIP_PAYLOAD_ORDER_ID, + &tcp_payload_callback, + &tcp_payload_sequence, + &tcp_payload_checksum, + &tcp_payload_bytes +); + +void reset_runtime_stats() { + stats = {}; + server_burst_remaining = 0; + client_burst_remaining = 0; + next_server_burst_attempt_ms = HAL_GetTick(); + next_client_burst_attempt_ms = HAL_GetTick(); +} + +void set_health_reason(uint32_t reason, uint32_t arg = 0) { + health.reason_last = reason; + health.reason_arg_last = arg; + health.reason_update_count++; +} + +void reset_health_telemetry() { + health = {}; + health.reason_last = HEALTH_REASON_NONE; +} + +void send_health_page(uint32_t page) { + const uint32_t uptime_ms = HAL_GetTick(); + switch (page) { + case 0: + queue_tcp_response( + CMD_GET_HEALTH, + uptime_ms, + health.loop_iterations, + stats.tcp_commands_rx + ); + break; + case 1: + queue_tcp_response( + CMD_GET_HEALTH, + stats.tcp_payload_rx, + stats.tcp_payload_bad, + stats.tcp_responses_tx + ); + break; + case 2: + queue_tcp_response( + CMD_GET_HEALTH, + stats.tcp_client_tx_ok, + stats.tcp_client_tx_fail, + health.tcp_client_send_fail_streak_max + ); + break; + case 3: + queue_tcp_response( + CMD_GET_HEALTH, + health.tcp_server_recreate_count, + health.tcp_client_recreate_count, + health.tcp_client_reconnect_calls + ); + break; + case 4: + queue_tcp_response( + CMD_GET_HEALTH, + health.reason_last, + health.reason_arg_last, + health.reason_update_count + ); + break; + case 5: + queue_tcp_response( + CMD_GET_HEALTH, + health.server_burst_requested_max, + health.client_burst_requested_max, + health.tcp_client_not_connected_ticks + ); + break; + default: + queue_tcp_response(CMD_GET_HEALTH, page, HEALTH_PAGE_COUNT, 0xDEAD0001u); + break; + } +} + +void recreate_tcp_server(uint32_t reason = HEALTH_REASON_SERVER_RECREATE, uint32_t arg = 0) { + if (tcp_server != nullptr) { + delete tcp_server; + tcp_server = nullptr; + } + tcp_server = new Server(IPV4(TCPIP_STRINGIFY(TCPIP_TEST_BOARD_IP)), TCPIP_TEST_TCP_SERVER_PORT); + health.tcp_server_recreate_count++; + set_health_reason(reason, arg); +} + +void recreate_tcp_client(uint32_t reason, uint32_t arg = 0) { + if (tcp_client != nullptr) { + delete tcp_client; + tcp_client = nullptr; + } + tcp_client = new Socket( + IPV4(TCPIP_STRINGIFY(TCPIP_TEST_BOARD_IP)), + TCPIP_TEST_TCP_CLIENT_LOCAL_PORT, + IPV4(TCPIP_STRINGIFY(TCPIP_TEST_HOST_IP)), + TCPIP_TEST_TCP_CLIENT_REMOTE_PORT + ); + health.tcp_client_recreate_count++; + health.tcp_client_send_fail_streak = 0; + set_health_reason(reason, arg); +} + +void process_pending_command() { + if (tcp_response_retry_pending) { + flush_pending_tcp_response(); + if (tcp_response_retry_pending) { + return; + } + } + + if (!pending_command) { + return; + } + pending_command = false; + health.last_cmd_opcode = command_opcode; + health.last_cmd_at_ms = HAL_GetTick(); + + switch (command_opcode) { + case CMD_RESET: + reset_runtime_stats(); + queue_tcp_response(CMD_RESET, 0, 0, 0); + break; + + case CMD_PING: + queue_tcp_response( + CMD_PING, + command_arg0, + (tcp_server != nullptr) ? tcp_server->connections_count() : 0, + stats.tcp_payload_rx + ); + break; + + case CMD_GET_STATS: + queue_tcp_response( + CMD_GET_STATS, + stats.tcp_payload_rx, + stats.tcp_payload_bad, + stats.forced_disconnects + ); + break; + + case CMD_FORCE_DISCONNECT: + stats.forced_disconnects++; + queue_tcp_response(CMD_FORCE_DISCONNECT, stats.forced_disconnects, 0, 0); + force_disconnect_requested = true; + set_health_reason(HEALTH_REASON_CMD_FORCE_DISCONNECT, stats.forced_disconnects); + break; + + case CMD_BURST_SERVER: + server_burst_remaining = command_arg0; + if (server_burst_remaining > health.server_burst_requested_max) { + health.server_burst_requested_max = server_burst_remaining; + } + next_server_burst_attempt_ms = HAL_GetTick(); + queue_tcp_response(CMD_BURST_SERVER, server_burst_remaining, 0, 0); + break; + + case CMD_BURST_CLIENT: + client_burst_remaining = command_arg0; + if (client_burst_remaining > health.client_burst_requested_max) { + health.client_burst_requested_max = client_burst_remaining; + } + next_client_burst_attempt_ms = HAL_GetTick(); + queue_tcp_response(CMD_BURST_CLIENT, client_burst_remaining, 0, 0); + break; + + case CMD_FORCE_CLIENT_RECONNECT: + recreate_tcp_client(HEALTH_REASON_CLIENT_RECREATE_CMD, command_arg0); + queue_tcp_response(CMD_FORCE_CLIENT_RECONNECT, 1, 0, 0); + break; + + case CMD_GET_HEALTH: + send_health_page(command_arg0); + break; + + case CMD_RESET_HEALTH: + reset_health_telemetry(); + queue_tcp_response(CMD_RESET_HEALTH, 1, 0, 0); + break; + + default: + queue_tcp_response(0xFFFFFFFFu, command_opcode, command_arg0, command_arg1); + break; + } +} + +void process_udp_probe() { + if (!udp_probe_seen && udp_probe_sequence == 0 && udp_probe_checksum == 0) { + return; + } + + if (!udp_probe_seen || udp_probe_sequence != last_udp_probe_sequence) { + udp_probe_seen = true; + last_udp_probe_sequence = udp_probe_sequence; + stats.udp_probe_rx++; + + if (checksum32(udp_probe_bytes.data(), udp_probe_bytes.size()) != udp_probe_checksum) { + stats.udp_probe_bad++; + } + + udp_status_sequence = udp_probe_sequence; + udp_status_ok = stats.udp_probe_rx - stats.udp_probe_bad; + udp_status_bad = stats.udp_probe_bad; + + if (udp_socket != nullptr && udp_socket->send_packet(udp_status_packet)) { + stats.udp_status_tx_ok++; + } else { + stats.udp_status_tx_fail++; + } + } +} + +uint32_t send_server_stream_burst(uint32_t budget) { + if (tcp_server == nullptr) { + return 0; + } + + uint32_t sent_ok = 0; + for (uint32_t sent = 0; sent < budget && server_burst_remaining > 0; sent++) { + fill_pattern( + tcp_server_stream_bytes.data(), + tcp_server_stream_bytes.size(), + tcp_server_stream_sequence + ); + if (!tcp_server->broadcast_order(tcp_server_stream_order)) { + break; + } + tcp_server_stream_sequence++; + server_burst_remaining--; + sent_ok++; + } + return sent_ok; +} + +void note_client_send_failure() { + stats.tcp_client_tx_fail++; + health.tcp_client_send_fail_events++; + health.tcp_client_send_fail_streak++; + if (health.tcp_client_send_fail_streak > health.tcp_client_send_fail_streak_max) { + health.tcp_client_send_fail_streak_max = health.tcp_client_send_fail_streak; + } + if (health.tcp_client_send_fail_streak == 1) { + set_health_reason(HEALTH_REASON_CLIENT_SEND_FAIL, stats.tcp_client_tx_fail); + } +} + +uint32_t send_client_stream(uint32_t amount) { + client_send_failed_last = false; + if (tcp_client == nullptr) { + return 0; + } + + uint32_t sent_ok = 0; + for (uint32_t sent = 0; sent < amount; sent++) { + if (!tcp_client->is_connected()) { + break; + } + + tcp_client_stream_sequence++; + tcp_client_stream_ok = stats.tcp_client_tx_ok; + tcp_client_stream_fail = stats.tcp_client_tx_fail; + + if (tcp_client->send_order(tcp_client_stream_order)) { + stats.tcp_client_tx_ok++; + health.tcp_client_send_ok_events++; + health.tcp_client_send_fail_streak = 0; + sent_ok++; + } else { + tcp_client_stream_sequence--; + note_client_send_failure(); + client_send_failed_last = true; + break; + } + } + return sent_ok; +} + +} // namespace + +int main(void) { + Hard_fault_check(); + + ExampleTCPIPBoard::init(); + + auto& eth_instance = ExampleTCPIPBoard::instance_of(); + auto& led_instance = ExampleTCPIPBoard::instance_of(); + + recreate_tcp_server(HEALTH_REASON_BOOT, 0); + recreate_tcp_client(HEALTH_REASON_BOOT, 0); + udp_socket = new DatagramSocket( + IPV4(TCPIP_STRINGIFY(TCPIP_TEST_BOARD_IP)), + TCPIP_TEST_UDP_LOCAL_PORT, + IPV4(TCPIP_STRINGIFY(TCPIP_TEST_HOST_IP)), + TCPIP_TEST_UDP_REMOTE_PORT + ); + + uint32_t last_led_toggle_ms = HAL_GetTick(); + uint32_t last_client_heartbeat_ms = HAL_GetTick(); + uint32_t last_client_reconnect_ms = HAL_GetTick(); + uint32_t last_client_fail_recreate_ms = HAL_GetTick(); + + led_instance.turn_on(); + + while (1) { + health.loop_iterations++; + eth_instance.update(); + Server::update_servers(); + + process_pending_command(); + flush_pending_tcp_response(); + process_udp_probe(); + + const uint32_t now = HAL_GetTick(); + const bool control_tx_ready = !tcp_response_retry_pending; + + if (force_disconnect_requested) { + force_disconnect_requested = false; + recreate_tcp_server(HEALTH_REASON_SERVER_RECREATE, stats.forced_disconnects); + } + + if (control_tx_ready && server_burst_remaining > 0 && now >= next_server_burst_attempt_ms) { + const uint32_t sent_now = send_server_stream_burst(STREAMS_PER_LOOP); + next_server_burst_attempt_ms = + now + ((sent_now > 0) ? STREAM_SUCCESS_SPACING_MS : STREAM_RETRY_BACKOFF_MS); + } + + if (control_tx_ready && client_burst_remaining > 0 && now >= next_client_burst_attempt_ms) { + const uint32_t burst_budget = (client_burst_remaining > STREAMS_PER_LOOP) + ? STREAMS_PER_LOOP + : client_burst_remaining; + const uint32_t sent_now = send_client_stream(burst_budget); + if (sent_now <= client_burst_remaining) { + client_burst_remaining -= sent_now; + } else { + client_burst_remaining = 0; + } + if (sent_now > 0) { + next_client_burst_attempt_ms = now + STREAM_SUCCESS_SPACING_MS; + } else if (tcp_client == nullptr || !tcp_client->is_connected()) { + next_client_burst_attempt_ms = now + STREAM_DISCONNECTED_BACKOFF_MS; + } else if (client_send_failed_last) { + next_client_burst_attempt_ms = now + STREAM_FAIL_STREAK_BACKOFF_MS; + } else { + next_client_burst_attempt_ms = now + STREAM_SUCCESS_SPACING_MS; + } + } + + if (control_tx_ready && server_burst_remaining == 0 && client_burst_remaining == 0 && + now - last_client_heartbeat_ms >= CLIENT_HEARTBEAT_MS) { + send_client_stream(1); + last_client_heartbeat_ms = now; + } + + if (tcp_client != nullptr) { + if (!tcp_client->is_connected()) { + health.tcp_client_not_connected_ticks++; + } + if (!tcp_client->is_connected() && + (now - last_client_reconnect_ms >= CLIENT_RECONNECT_MS)) { + health.tcp_client_reconnect_calls++; + set_health_reason( + HEALTH_REASON_CLIENT_RECONNECT_POLL, + health.tcp_client_reconnect_calls + ); + tcp_client->reconnect(); + last_client_reconnect_ms = now; + if (CLIENT_RECONNECT_RECREATE_EVERY > 0 && + health.tcp_client_reconnect_calls % CLIENT_RECONNECT_RECREATE_EVERY == 0) { + recreate_tcp_client( + HEALTH_REASON_CLIENT_RECREATE_WATCHDOG, + health.tcp_client_reconnect_calls + ); + } + } + if (health.tcp_client_send_fail_streak >= CLIENT_SEND_FAIL_RECREATE_THRESHOLD && + (now - last_client_fail_recreate_ms >= CLIENT_SEND_FAIL_RECREATE_MIN_INTERVAL_MS)) { + recreate_tcp_client( + HEALTH_REASON_CLIENT_RECREATE_WATCHDOG, + health.tcp_client_send_fail_streak + ); + last_client_fail_recreate_ms = now; + next_client_burst_attempt_ms = now + STREAM_DISCONNECTED_BACKOFF_MS; + } + } + + if (now - last_led_toggle_ms >= LED_TOGGLE_MS) { + led_instance.toggle(); + last_led_toggle_ms = now; + } + } +} + +#else + +constexpr auto led = ST_LIB::DigitalOutputDomain::DigitalOutput(ST_LIB::PB0); +using ExampleTCPIPBoard = ST_LIB::Board; + +int main(void) { + ExampleTCPIPBoard::init(); + auto& led_instance = ExampleTCPIPBoard::instance_of(); + + while (1) { + led_instance.toggle(); + HAL_Delay(200); + } +} + +#endif // STLIB_ETH +#endif // EXAMPLE_TCPIP diff --git a/deps/ST-LIB b/deps/ST-LIB index fbd5e2e6..ef1f2561 160000 --- a/deps/ST-LIB +++ b/deps/ST-LIB @@ -1 +1 @@ -Subproject commit fbd5e2e6d8a8c2e9155db86f06a317f58cdea79d +Subproject commit ef1f2561da687b84581e5f5b61cea1edc5f5d9ab diff --git a/tools/configure_nucleo_host_network_macos.sh b/tools/configure_nucleo_host_network_macos.sh new file mode 100755 index 00000000..ef802fbe --- /dev/null +++ b/tools/configure_nucleo_host_network_macos.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -euo pipefail + +service_name="USB 10/100/1000 LAN" +iface="en6" +wifi_service="Wi-Fi" +host_ip="192.168.1.9" +subnet_mask="255.255.255.0" +router_ip="0.0.0.0" +board_ip="192.168.1.7" +check_board=0 +disable_service=0 + +usage() { + cat <<'EOF' +Usage: tools/configure_nucleo_host_network_macos.sh [options] + +Safe host-side setup for running Nucleo Ethernet tests while preserving Wi-Fi. + +Options: + --service Network service name (default: USB 10/100/1000 LAN) + --iface Interface device name (default: en6) + --wifi-service Wi-Fi service name to keep first (default: Wi-Fi) + --host-ip Host IPv4 for the Nucleo link (default: 192.168.1.9) + --mask Host subnet mask (default: 255.255.255.0) + --router Router for USB service (default: 0.0.0.0) + --board-ip Board IPv4 to preflight (default: 192.168.1.7) + --check-board Also run board ping preflight + --disable-service Disable the USB service and exit + -h, --help Show help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --service) service_name="$2"; shift 2 ;; + --iface) iface="$2"; shift 2 ;; + --wifi-service) wifi_service="$2"; shift 2 ;; + --host-ip) host_ip="$2"; shift 2 ;; + --mask) subnet_mask="$2"; shift 2 ;; + --router) router_ip="$2"; shift 2 ;; + --board-ip) board_ip="$2"; shift 2 ;; + --check-board) check_board=1; shift ;; + --disable-service) disable_service=1; shift ;; + -h|--help) usage; exit 0 ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 2 + ;; + esac +done + +if ! command -v networksetup >/dev/null 2>&1; then + echo "networksetup not found (macOS required)" >&2 + exit 2 +fi + +if [[ "${disable_service}" -eq 1 ]]; then + networksetup -setnetworkserviceenabled "${service_name}" off + echo "Disabled service: ${service_name}" + exit 0 +fi + +declare -a all_services=() +while IFS= read -r line; do + [[ -z "${line}" ]] && continue + if [[ "${line}" == "An asterisk (*) denotes that a network service is disabled." ]]; then + continue + fi + all_services+=("${line#\*}") +done < <(networksetup -listallnetworkservices) + +if [[ ${#all_services[@]} -eq 0 ]]; then + echo "Could not read network services" >&2 + exit 2 +fi + +found_service=0 +for item in "${all_services[@]}"; do + if [[ "${item}" == "${service_name}" ]]; then + found_service=1 + break + fi +done +if [[ "${found_service}" -ne 1 ]]; then + echo "Service not found: ${service_name}" >&2 + exit 2 +fi + +wifi_found=0 +for item in "${all_services[@]}"; do + if [[ "${item}" == "${wifi_service}" ]]; then + wifi_found=1 + break + fi +done + +if [[ "${wifi_found}" -ne 1 ]]; then + echo "Wi-Fi service not found as '${wifi_service}', preserving current order for other services." >&2 +fi + +declare -a reordered=() +if [[ "${wifi_found}" -eq 1 ]]; then + reordered+=("${wifi_service}") +fi +for item in "${all_services[@]}"; do + if [[ "${item}" == "${service_name}" ]]; then + continue + fi + if [[ "${wifi_found}" -eq 1 && "${item}" == "${wifi_service}" ]]; then + continue + fi + reordered+=("${item}") +done +reordered+=("${service_name}") + +networksetup -ordernetworkservices "${reordered[@]}" +networksetup -setnetworkserviceenabled "${service_name}" on +networksetup -setmanual "${service_name}" "${host_ip}" "${subnet_mask}" "${router_ip}" + +echo "CONFIG service=${service_name} iface=${iface} host_ip=${host_ip} board_ip=${board_ip}" +ifconfig "${iface}" | sed -n '1,80p' +echo "---" +route -n get default || true +echo "---" +route -n get "${board_ip}" || true +echo "---" +scutil --nwi | sed -n '1,120p' + +if ! ifconfig "${iface}" | rg -Fq "inet ${host_ip} "; then + echo "Interface ${iface} does not hold expected IPv4 ${host_ip}" >&2 + exit 3 +fi + +default_if="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')" +if [[ "${default_if}" == "${iface}" ]]; then + echo "Default route moved to ${iface}; this may break Wi-Fi. Aborting." >&2 + exit 4 +fi + +board_route_if="$(route -n get "${board_ip}" 2>/dev/null | awk '/interface:/{print $2; exit}')" +if [[ "${board_route_if}" != "${iface}" ]]; then + echo "Board route is not using ${iface} (got: ${board_route_if:-none})." >&2 + echo "This is expected on macOS when Wi-Fi owns the 192.168.1.0/24 route." >&2 + echo "Host tools should source-bind to ${host_ip} to reach the Nucleo while preserving Wi-Fi." >&2 +fi + +internet_probe="$(ping -c 1 -W 1000 1.1.1.1 || true)" +if ! printf '%s\n' "${internet_probe}" | rg -q "bytes from 1.1.1.1"; then + echo "Internet probe failed after setup; check Wi-Fi/service order." >&2 + exit 6 +fi + +gateway_ip="$(route -n get default 2>/dev/null | awk '/gateway:/{print $2; exit}')" +if [[ -n "${gateway_ip}" ]]; then + local_probe="$(nc -vz -w 2 "${gateway_ip}" 80 2>&1 || true)" + if printf '%s\n' "${local_probe}" | rg -q "No route to host"; then + cat >&2 < Privacy & Security > Local Network +EOF + exit 7 + fi +fi + +if [[ "${check_board}" -eq 1 ]]; then + board_ping="$(ping -S "${host_ip}" -c 3 -W 1000 "${board_ip}" || true)" + printf '%s\n' "${board_ping}" + if ! printf '%s\n' "${board_ping}" | rg -q "bytes from ${board_ip}"; then + echo "Board ping failed for ${board_ip}" >&2 + exit 8 + fi +fi + +echo "HOST_NETWORK_READY" diff --git a/tools/example_tcpip_quality_gate.sh b/tools/example_tcpip_quality_gate.sh new file mode 100755 index 00000000..f7884302 --- /dev/null +++ b/tools/example_tcpip_quality_gate.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +repo_root="$(CDPATH= cd -- "${script_dir}/.." && pwd)" +stress_script="${repo_root}/tools/run_example_tcpip_stress.sh" + +board_ip="192.168.1.7" +host_bind="0.0.0.0" +base_runs=20 +aggr_runs=5 +base_good=1200 +base_bad=200 +base_server=800 +base_client=800 +base_udp=300 +aggr_good=5000 +aggr_bad=1000 +aggr_server=4000 +aggr_client=4000 +aggr_udp=2000 +payload_interval_us=800 +min_payload_rx_ratio=0.90 +min_bad_detect_ratio=0.80 +min_server_burst_ratio=0.95 +min_client_burst_ratio=0.95 +control_mode="auto" +strict_client_stream=1 +health_on_fail=1 +health_pages=6 +health_at_end=0 +stop_on_first_fail=0 +inter_run_sleep_s="1.5" +preflight_ping_retries=8 + +usage() { + cat <<'EOF' +Usage: tools/example_tcpip_quality_gate.sh [options] + +Options: + --board-ip Board IP (default: 192.168.1.7) + --host-bind Host bind IP (default: 0.0.0.0) + --base-runs Number of strict base runs (default: 20) + --aggr-runs Number of strict aggressive runs (default: 5) + --payload-interval-us Payload pacing (default: 800) + --base-good Base good payloads (default: 1200) + --base-bad Base bad payloads (default: 200) + --base-server Base server burst (default: 800) + --base-client Base client burst (default: 800) + --base-udp Base udp count (default: 300) + --aggr-good Aggressive good payloads (default: 5000) + --aggr-bad Aggressive bad payloads (default: 1000) + --aggr-server Aggressive server burst (default: 4000) + --aggr-client Aggressive client burst (default: 4000) + --aggr-udp Aggressive udp count (default: 2000) + --control-mode auto|server|client (default: auto) + --min-payload-rx-ratio Min payload RX ratio (default: 0.90) + --min-bad-detect-ratio Min bad-checksum detect ratio (default: 0.80) + --min-server-burst-ratio Min server burst receive ratio (default: 0.95) + --min-client-burst-ratio Min client burst receive ratio (default: 0.95) + --health-pages Number of health pages per run (default: 6) + --health-at-end Print health pages at the end of each run + --no-strict-client-stream Do not enforce strict tcp_client_stream + --no-health-on-fail Disable health dumps on failed runs + --stop-on-first-fail Stop quality gate immediately on first failed run + --inter-run-sleep Sleep time between runs in seconds (default: 1.5) + --preflight-ping-retries Number of ping retries before each run (default: 8) + -h, --help Show help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --board-ip) board_ip="$2"; shift 2 ;; + --host-bind) host_bind="$2"; shift 2 ;; + --base-runs) base_runs="$2"; shift 2 ;; + --aggr-runs) aggr_runs="$2"; shift 2 ;; + --payload-interval-us) payload_interval_us="$2"; shift 2 ;; + --base-good) base_good="$2"; shift 2 ;; + --base-bad) base_bad="$2"; shift 2 ;; + --base-server) base_server="$2"; shift 2 ;; + --base-client) base_client="$2"; shift 2 ;; + --base-udp) base_udp="$2"; shift 2 ;; + --aggr-good) aggr_good="$2"; shift 2 ;; + --aggr-bad) aggr_bad="$2"; shift 2 ;; + --aggr-server) aggr_server="$2"; shift 2 ;; + --aggr-client) aggr_client="$2"; shift 2 ;; + --aggr-udp) aggr_udp="$2"; shift 2 ;; + --control-mode) control_mode="$2"; shift 2 ;; + --min-payload-rx-ratio) min_payload_rx_ratio="$2"; shift 2 ;; + --min-bad-detect-ratio) min_bad_detect_ratio="$2"; shift 2 ;; + --min-server-burst-ratio) min_server_burst_ratio="$2"; shift 2 ;; + --min-client-burst-ratio) min_client_burst_ratio="$2"; shift 2 ;; + --health-pages) health_pages="$2"; shift 2 ;; + --health-at-end) health_at_end=1; shift ;; + --no-strict-client-stream) strict_client_stream=0; shift ;; + --no-health-on-fail) health_on_fail=0; shift ;; + --stop-on-first-fail) stop_on_first_fail=1; shift ;; + --inter-run-sleep) inter_run_sleep_s="$2"; shift 2 ;; + --preflight-ping-retries) preflight_ping_retries="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 2 + ;; + esac +done + +timestamp="$(date +%Y%m%d_%H%M%S)" +log_dir="${repo_root}/out/quality-gate/${timestamp}" +mkdir -p "${log_dir}" + +run_case() { + local suite="$1" + local run_idx="$2" + shift 2 + local -a cmd=("$@") + + local log_file="${log_dir}/${suite}_run_${run_idx}.log" + echo "[$suite] run ${run_idx} -> ${log_file}" + if "${cmd[@]}" >"${log_file}" 2>&1; then + local overall + overall="$(rg -n "^Overall:" "${log_file}" | tail -n1 | cut -d: -f2- || true)" + echo "[$suite] run ${run_idx} PASS ${overall}" + return 0 + fi + + local overall + overall="$(rg -n "^Overall:" "${log_file}" | tail -n1 | cut -d: -f2- || true)" + echo "[$suite] run ${run_idx} FAIL ${overall}" + return 1 +} + +wait_board_reachable() { + local retries="$1" + local i + for ((i=1; i<=retries; i++)); do + if ping -c 1 -W 1000 "${board_ip}" >/dev/null 2>&1; then + return 0 + fi + sleep 0.5 + done + return 1 +} + +base_failures=0 +aggr_failures=0 + +declare -a common_args +common_args=( + --board-ip "${board_ip}" + --host-bind "${host_bind}" + --control-mode "${control_mode}" + --payload-interval-us "${payload_interval_us}" + --min-payload-rx-ratio "${min_payload_rx_ratio}" + --min-bad-detect-ratio "${min_bad_detect_ratio}" + --min-server-burst-ratio "${min_server_burst_ratio}" + --min-client-burst-ratio "${min_client_burst_ratio}" + --health-pages "${health_pages}" + --reset-health +) +if [[ "${strict_client_stream}" -eq 1 ]]; then + common_args+=(--strict-client-stream) +fi +if [[ "${health_on_fail}" -eq 0 ]]; then + common_args+=(--no-health-on-fail) +fi +if [[ "${health_at_end}" -eq 1 ]]; then + common_args+=(--health-at-end) +fi + +for ((i=1; i<=base_runs; i++)); do + if ! wait_board_reachable "${preflight_ping_retries}"; then + echo "[base] run ${i} FAIL preflight_ping board_ip=${board_ip}" + base_failures=$((base_failures + 1)) + if [[ "${stop_on_first_fail}" -eq 1 ]]; then + break + fi + sleep "${inter_run_sleep_s}" + continue + fi + + if ! run_case "base" "${i}" \ + "${stress_script}" \ + "${common_args[@]}" \ + --good-payloads "${base_good}" \ + --bad-payloads "${base_bad}" \ + --server-burst "${base_server}" \ + --client-burst "${base_client}" \ + --udp-count "${base_udp}"; then + base_failures=$((base_failures + 1)) + if [[ "${stop_on_first_fail}" -eq 1 ]]; then + break + fi + fi + sleep "${inter_run_sleep_s}" +done + +if [[ "${stop_on_first_fail}" -eq 0 || "${base_failures}" -eq 0 ]]; then + for ((i=1; i<=aggr_runs; i++)); do + if ! wait_board_reachable "${preflight_ping_retries}"; then + echo "[aggressive] run ${i} FAIL preflight_ping board_ip=${board_ip}" + aggr_failures=$((aggr_failures + 1)) + if [[ "${stop_on_first_fail}" -eq 1 ]]; then + break + fi + sleep "${inter_run_sleep_s}" + continue + fi + + if ! run_case "aggressive" "${i}" \ + "${stress_script}" \ + "${common_args[@]}" \ + --good-payloads "${aggr_good}" \ + --bad-payloads "${aggr_bad}" \ + --server-burst "${aggr_server}" \ + --client-burst "${aggr_client}" \ + --udp-count "${aggr_udp}"; then + aggr_failures=$((aggr_failures + 1)) + if [[ "${stop_on_first_fail}" -eq 1 ]]; then + break + fi + fi + sleep "${inter_run_sleep_s}" + done +fi + +echo +echo "QUALITY_GATE_SUMMARY base_runs=${base_runs} base_failures=${base_failures} aggr_runs=${aggr_runs} aggr_failures=${aggr_failures} log_dir=${log_dir}" +if [[ "${base_failures}" -eq 0 && "${aggr_failures}" -eq 0 ]]; then + echo "QUALITY_GATE_RESULT PASS" + exit 0 +fi + +echo "QUALITY_GATE_RESULT FAIL" +exit 1 diff --git a/tools/example_tcpip_soak.sh b/tools/example_tcpip_soak.sh new file mode 100755 index 00000000..76eed18e --- /dev/null +++ b/tools/example_tcpip_soak.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +repo_root="$(CDPATH= cd -- "${script_dir}/.." && pwd)" +stress_script="${repo_root}/tools/run_example_tcpip_stress.sh" + +board_ip="192.168.1.7" +host_bind="0.0.0.0" +duration_min=120 +interval_sec=2 +payload_interval_us=800 +min_payload_rx_ratio=0.90 +min_bad_detect_ratio=0.80 +min_server_burst_ratio=0.95 +min_client_burst_ratio=0.95 +control_mode="auto" +good_payloads=2000 +bad_payloads=400 +server_burst=1200 +client_burst=1200 +udp_count=600 +strict_client_stream=0 +health_on_fail=1 +health_pages=6 +health_at_end=0 +max_failures=1 + +usage() { + cat <<'EOF' +Usage: tools/example_tcpip_soak.sh [options] + +Options: + --board-ip Board IP (default: 192.168.1.7) + --host-bind Host bind IP (default: 0.0.0.0) + --duration-min Soak duration in minutes (default: 120) + --interval-sec Sleep between runs (default: 2) + --payload-interval-us Payload pacing (default: 800) + --control-mode auto|server|client (default: auto) + --min-payload-rx-ratio Min payload RX ratio (default: 0.90) + --min-bad-detect-ratio Min bad-checksum detect ratio (default: 0.80) + --min-server-burst-ratio Min server burst receive ratio (default: 0.95) + --min-client-burst-ratio Min client burst receive ratio (default: 0.95) + --good-payloads Good payloads per run (default: 2000) + --bad-payloads Bad payloads per run (default: 400) + --server-burst Server burst per run (default: 1200) + --client-burst Client burst per run (default: 1200) + --udp-count UDP probes per run (default: 600) + --health-pages Number of health pages to query (default: 6) + --health-at-end Print health pages at the end of each run + --strict-client-stream Fail each run on client-stream instability + --no-health-on-fail Disable health dump on failures + --max-failures Stop soak after this many failures (default: 1) + -h, --help Show help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --board-ip) board_ip="$2"; shift 2 ;; + --host-bind) host_bind="$2"; shift 2 ;; + --duration-min) duration_min="$2"; shift 2 ;; + --interval-sec) interval_sec="$2"; shift 2 ;; + --payload-interval-us) payload_interval_us="$2"; shift 2 ;; + --control-mode) control_mode="$2"; shift 2 ;; + --min-payload-rx-ratio) min_payload_rx_ratio="$2"; shift 2 ;; + --min-bad-detect-ratio) min_bad_detect_ratio="$2"; shift 2 ;; + --min-server-burst-ratio) min_server_burst_ratio="$2"; shift 2 ;; + --min-client-burst-ratio) min_client_burst_ratio="$2"; shift 2 ;; + --good-payloads) good_payloads="$2"; shift 2 ;; + --bad-payloads) bad_payloads="$2"; shift 2 ;; + --server-burst) server_burst="$2"; shift 2 ;; + --client-burst) client_burst="$2"; shift 2 ;; + --udp-count) udp_count="$2"; shift 2 ;; + --health-pages) health_pages="$2"; shift 2 ;; + --health-at-end) health_at_end=1; shift ;; + --strict-client-stream) strict_client_stream=1; shift ;; + --no-health-on-fail) health_on_fail=0; shift ;; + --max-failures) max_failures="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 2 + ;; + esac +done + +timestamp="$(date +%Y%m%d_%H%M%S)" +log_dir="${repo_root}/out/soak/${timestamp}" +mkdir -p "${log_dir}" + +deadline_epoch=$(( $(date +%s) + duration_min * 60 )) +run_idx=0 +pass_count=0 +fail_count=0 + +echo "SOAK_START duration_min=${duration_min} interval_sec=${interval_sec} board_ip=${board_ip} log_dir=${log_dir}" + +while [[ $(date +%s) -lt ${deadline_epoch} ]]; do + run_idx=$((run_idx + 1)) + log_file="${log_dir}/soak_run_${run_idx}.log" + cmd=( + "${stress_script}" + --board-ip "${board_ip}" + --host-bind "${host_bind}" + --control-mode "${control_mode}" + --payload-interval-us "${payload_interval_us}" + --min-payload-rx-ratio "${min_payload_rx_ratio}" + --min-bad-detect-ratio "${min_bad_detect_ratio}" + --min-server-burst-ratio "${min_server_burst_ratio}" + --min-client-burst-ratio "${min_client_burst_ratio}" + --good-payloads "${good_payloads}" + --bad-payloads "${bad_payloads}" + --server-burst "${server_burst}" + --client-burst "${client_burst}" + --udp-count "${udp_count}" + --health-pages "${health_pages}" + --reset-health + ) + if [[ "${strict_client_stream}" -eq 1 ]]; then + cmd+=(--strict-client-stream) + fi + if [[ "${health_on_fail}" -eq 0 ]]; then + cmd+=(--no-health-on-fail) + fi + if [[ "${health_at_end}" -eq 1 ]]; then + cmd+=(--health-at-end) + fi + + echo "[SOAK] run=${run_idx} -> ${log_file}" + if "${cmd[@]}" >"${log_file}" 2>&1; then + pass_count=$((pass_count + 1)) + overall="$(rg -n "^Overall:" "${log_file}" | tail -n1 | cut -d: -f2- || true)" + echo "[SOAK] run=${run_idx} PASS ${overall}" + else + fail_count=$((fail_count + 1)) + overall="$(rg -n "^Overall:" "${log_file}" | tail -n1 | cut -d: -f2- || true)" + echo "[SOAK] run=${run_idx} FAIL ${overall}" + if [[ "${fail_count}" -ge "${max_failures}" ]]; then + echo "[SOAK] stopping early: fail_count=${fail_count} reached max_failures=${max_failures}" + break + fi + fi + + sleep "${interval_sec}" +done + +echo +echo "SOAK_SUMMARY runs=${run_idx} pass=${pass_count} fail=${fail_count} log_dir=${log_dir}" +if [[ "${fail_count}" -eq 0 ]]; then + echo "SOAK_RESULT PASS" + exit 0 +fi + +echo "SOAK_RESULT FAIL" +exit 1 diff --git a/tools/example_tcpip_soak_hours.sh b/tools/example_tcpip_soak_hours.sh new file mode 100755 index 00000000..6df96841 --- /dev/null +++ b/tools/example_tcpip_soak_hours.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +set -euo pipefail +export LC_ALL=C + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +repo_root="$(CDPATH= cd -- "${script_dir}/.." && pwd)" +soak_script="${repo_root}/tools/example_tcpip_soak.sh" + +board_ip="192.168.1.7" +host_bind="0.0.0.0" +hours=8 +interval_sec=1 +payload_interval_us=800 +control_mode="auto" +strict_client_stream=1 +max_failures=999999 +min_pass_ratio="0.90" +baseline_pass_ratio="" + +good_payloads=2500 +bad_payloads=500 +server_burst=1500 +client_burst=1500 +udp_count=800 +health_pages=6 +health_on_fail=1 +health_at_end=0 + +usage() { + cat <<'EOF' +Usage: tools/example_tcpip_soak_hours.sh [options] + +Long soak wrapper intended for multi-hour / overnight runs. +Runs tools/example_tcpip_soak.sh and prints pass ratio summary. + +Options: + --board-ip Board IP (default: 192.168.1.7) + --host-bind Host bind IP (default: 0.0.0.0) + --hours Duration in hours (default: 8) + --interval-sec Sleep between runs (default: 1) + --payload-interval-us Payload pacing (default: 800) + --control-mode auto|server|client (default: auto) + --no-strict-client-stream Disable strict client-stream check + --max-failures Stop soak after N failures (default: 999999) + --min-pass-ratio Required pass ratio [0..1] (default: 0.90) + --baseline-pass-ratio Optional baseline ratio to compare against + --good-payloads Good payloads per run (default: 2500) + --bad-payloads Bad payloads per run (default: 500) + --server-burst Server burst per run (default: 1500) + --client-burst Client burst per run (default: 1500) + --udp-count UDP probes per run (default: 800) + --health-pages Number of health pages (default: 6) + --no-health-on-fail Disable health dump on failures + --health-at-end Print health pages at end of each run + -h, --help Show help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --board-ip) board_ip="$2"; shift 2 ;; + --host-bind) host_bind="$2"; shift 2 ;; + --hours) hours="$2"; shift 2 ;; + --interval-sec) interval_sec="$2"; shift 2 ;; + --payload-interval-us) payload_interval_us="$2"; shift 2 ;; + --control-mode) control_mode="$2"; shift 2 ;; + --no-strict-client-stream) strict_client_stream=0; shift ;; + --max-failures) max_failures="$2"; shift 2 ;; + --min-pass-ratio) min_pass_ratio="$2"; shift 2 ;; + --baseline-pass-ratio) baseline_pass_ratio="$2"; shift 2 ;; + --good-payloads) good_payloads="$2"; shift 2 ;; + --bad-payloads) bad_payloads="$2"; shift 2 ;; + --server-burst) server_burst="$2"; shift 2 ;; + --client-burst) client_burst="$2"; shift 2 ;; + --udp-count) udp_count="$2"; shift 2 ;; + --health-pages) health_pages="$2"; shift 2 ;; + --no-health-on-fail) health_on_fail=0; shift ;; + --health-at-end) health_at_end=1; shift ;; + -h|--help) usage; exit 0 ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 2 + ;; + esac +done + +if ! awk -v x="${hours}" 'BEGIN{exit !(x > 0)}'; then + echo "--hours must be > 0" >&2 + exit 2 +fi + +for ratio_name in min_pass_ratio baseline_pass_ratio; do + ratio_value="${!ratio_name}" + if [[ -z "${ratio_value}" ]]; then + continue + fi + if ! awk -v x="${ratio_value}" 'BEGIN{exit !(x >= 0 && x <= 1)}'; then + echo "--${ratio_name//_/-} must be in [0,1]" >&2 + exit 2 + fi +done + +duration_min="$(awk -v h="${hours}" 'BEGIN{m=int(h*60 + 0.9999); if (m < 1) m=1; print m}')" +timestamp="$(date +%Y%m%d_%H%M%S)" +report_dir="${repo_root}/out/soak-hours" +mkdir -p "${report_dir}" +session_log="${report_dir}/${timestamp}.log" + +if ! ping -c 2 -W 1000 "${board_ip}" >/dev/null 2>&1; then + echo "Board is not reachable before soak start: ${board_ip}" >&2 + exit 3 +fi + +cmd=( + "${soak_script}" + --board-ip "${board_ip}" + --host-bind "${host_bind}" + --duration-min "${duration_min}" + --interval-sec "${interval_sec}" + --payload-interval-us "${payload_interval_us}" + --control-mode "${control_mode}" + --good-payloads "${good_payloads}" + --bad-payloads "${bad_payloads}" + --server-burst "${server_burst}" + --client-burst "${client_burst}" + --udp-count "${udp_count}" + --health-pages "${health_pages}" + --max-failures "${max_failures}" +) +if [[ "${strict_client_stream}" -eq 1 ]]; then + cmd+=(--strict-client-stream) +fi +if [[ "${health_on_fail}" -eq 0 ]]; then + cmd+=(--no-health-on-fail) +fi +if [[ "${health_at_end}" -eq 1 ]]; then + cmd+=(--health-at-end) +fi + +echo "SOAK_HOURS_START hours=${hours} duration_min=${duration_min} board_ip=${board_ip} session_log=${session_log}" +set +e +"${cmd[@]}" | tee "${session_log}" +soak_exit=${PIPESTATUS[0]} +set -e + +summary_line="$(rg -n "^SOAK_SUMMARY" "${session_log}" | tail -n1 | cut -d: -f2- || true)" +if [[ -z "${summary_line}" ]]; then + echo "SOAK_HOURS_RESULT FAIL reason=no_summary session_log=${session_log}" + exit "${soak_exit}" +fi + +extract_field() { + local key="$1" + local line="$2" + awk -v k="${key}" ' + { + for (i = 1; i <= NF; i++) { + split($i, kv, "=") + if (kv[1] == k) { + print substr($i, length(k) + 2) + exit + } + } + } + ' <<<"${line}" +} + +runs="$(extract_field "runs" "${summary_line}")" +pass_count="$(extract_field "pass" "${summary_line}")" +fail_count="$(extract_field "fail" "${summary_line}")" +log_dir="$(extract_field "log_dir" "${summary_line}")" + +if [[ -z "${runs}" || -z "${pass_count}" || -z "${fail_count}" ]]; then + echo "SOAK_HOURS_RESULT FAIL reason=invalid_summary session_log=${session_log}" + exit 1 +fi + +pass_ratio="$(awk -v p="${pass_count}" -v r="${runs}" 'BEGIN{ if (r==0) printf "0.0000"; else printf "%.4f", p/r }')" + +echo "SOAK_HOURS_SUMMARY runs=${runs} pass=${pass_count} fail=${fail_count} pass_ratio=${pass_ratio} required_min_ratio=${min_pass_ratio} log_dir=${log_dir} session_log=${session_log}" + +if [[ -n "${log_dir}" && -d "${log_dir}" ]]; then + fail_names="$(rg -n "^Overall: FAIL .*-> " "${log_dir}"/soak_run_*.log 2>/dev/null | sed 's/^.*-> //' || true)" + if [[ -n "${fail_names}" ]]; then + while IFS= read -r grouped_line; do + count="$(printf '%s\n' "${grouped_line}" | awk '{print $1}')" + name="$(printf '%s\n' "${grouped_line}" | sed 's/^[[:space:]]*[0-9][0-9]*[[:space:]]*//')" + echo "SOAK_HOURS_FAIL_BREAKDOWN name=${name} count=${count}" + done < <(printf '%s\n' "${fail_names}" | sort | uniq -c) + fi +fi + +if [[ -n "${baseline_pass_ratio}" ]]; then + ratio_delta="$(awk -v current="${pass_ratio}" -v baseline="${baseline_pass_ratio}" 'BEGIN{printf "%.4f", current-baseline}')" + echo "SOAK_HOURS_BASELINE baseline=${baseline_pass_ratio} current=${pass_ratio} delta=${ratio_delta}" +fi + +if ! awk -v current="${pass_ratio}" -v required="${min_pass_ratio}" 'BEGIN{exit !(current >= required)}'; then + echo "SOAK_HOURS_RESULT FAIL reason=pass_ratio_below_threshold" + exit 1 +fi + +echo "SOAK_HOURS_RESULT PASS" +exit 0 diff --git a/tools/example_tcpip_stress.py b/tools/example_tcpip_stress.py new file mode 100755 index 00000000..814605f2 --- /dev/null +++ b/tools/example_tcpip_stress.py @@ -0,0 +1,1141 @@ +#!/usr/bin/env python3 +""" +Stress/integration test harness for Core/Src/Examples/ExampleTCPIP.cpp. +""" + +from __future__ import annotations + +import argparse +import errno +import random +import select +import socket +import struct +import threading +import time +from dataclasses import dataclass +from typing import Dict, List, Literal, Optional, Tuple + +TCPIP_CMD_ORDER_ID = 0x7101 +TCPIP_RESPONSE_ORDER_ID = 0x7102 +TCPIP_PAYLOAD_ORDER_ID = 0x7103 +TCPIP_CLIENT_STREAM_ORDER_ID = 0x7104 +TCPIP_SERVER_STREAM_ORDER_ID = 0x7105 + +TCPIP_UDP_PROBE_PACKET_ID = 0x7201 +TCPIP_UDP_STATUS_PACKET_ID = 0x7202 + +CMD_RESET = 1 +CMD_PING = 2 +CMD_GET_STATS = 3 +CMD_FORCE_DISCONNECT = 4 +CMD_BURST_SERVER = 5 +CMD_BURST_CLIENT = 6 +CMD_FORCE_CLIENT_RECONNECT = 7 +CMD_GET_HEALTH = 8 +CMD_RESET_HEALTH = 9 + +CMD_FMT = " int: + checksum = 2166136261 + for value in data: + checksum ^= value + checksum = (checksum * 16777619) & 0xFFFFFFFF + return checksum + + +def build_pattern(seed: int, size: int) -> bytes: + return bytes(((seed + (index * 17)) & 0xFF) for index in range(size)) + + +class PacketStreamParser: + def __init__(self, packet_sizes: Dict[int, int]): + self.packet_sizes = packet_sizes + self.buffer = bytearray() + + def feed(self, data: bytes) -> List[Tuple[int, bytes]]: + self.buffer.extend(data) + packets: List[Tuple[int, bytes]] = [] + + while len(self.buffer) >= 2: + packet_id = struct.unpack_from(" None: + self.stop_event.set() + + def clear_error(self) -> None: + with self.lock: + self.last_error = None + + def snapshot(self) -> Tuple[int, int, int, Optional[str], float]: + with self.lock: + return ( + self.connections_seen, + self.active_connections, + self.client_stream_packets, + self.last_error, + self.last_connection_monotonic, + ) + + def run(self) -> None: + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + server.bind((self.bind_ip, self.port)) + server.listen(4) + server.settimeout(0.5) + except OSError as exc: + with self.lock: + self.last_error = f"sink startup failed: {exc}" + server.close() + return + + try: + while not self.stop_event.is_set(): + try: + conn, _ = server.accept() + except socket.timeout: + continue + except OSError as exc: + with self.lock: + self.last_error = f"accept failed: {exc}" + continue + + with self.lock: + self.connections_seen += 1 + self.active_connections += 1 + self.last_connection_monotonic = time.monotonic() + self.last_error = None + + conn.settimeout(0.5) + parser = PacketStreamParser( + {TCPIP_CLIENT_STREAM_ORDER_ID: struct.calcsize(TCP_CLIENT_STREAM_FMT)} + ) + with conn: + while not self.stop_event.is_set(): + try: + data = conn.recv(4096) + except socket.timeout: + continue + except OSError as exc: + if exc.errno not in ( + errno.ECONNRESET, + errno.ECONNABORTED, + errno.ENOTCONN, + errno.EPIPE, + ): + with self.lock: + self.last_error = f"recv failed: {exc}" + break + + if not data: + break + + for packet_id, packet in parser.feed(data): + if packet_id == TCPIP_CLIENT_STREAM_ORDER_ID and len(packet) == struct.calcsize( + TCP_CLIENT_STREAM_FMT + ): + with self.lock: + self.client_stream_packets += 1 + with self.lock: + if self.active_connections > 0: + self.active_connections -= 1 + finally: + server.close() + + +@dataclass +class ResponsePacket: + code: int + value0: int + value1: int + value2: int + + +class ExampleTcpIpTester: + def __init__( + self, + board_ip: str, + tcp_server_port: int, + tcp_client_port: int, + udp_local_port: int, + udp_remote_port: int, + host_bind: str, + control_mode: Literal["server", "client", "auto"] = "auto", + ): + self.board_ip = board_ip + self.tcp_server_port = tcp_server_port + self.tcp_client_port = tcp_client_port + self.udp_local_port = udp_local_port + self.udp_remote_port = udp_remote_port + self.host_bind = host_bind + self.control_mode_requested = control_mode + self.active_control_mode: Literal["server", "client"] = "server" + + self.control_socket: Optional[socket.socket] = None + self.control_listener_socket: Optional[socket.socket] = None + self.control_parser = PacketStreamParser(PACKET_SIZES) + self.server_stream_packets = 0 + self.client_stream_packets = 0 + + def connect_control(self, timeout_s: float = 10.0) -> None: + if self.control_mode_requested == "client": + self._connect_control_via_client(timeout_s=timeout_s) + return + + try: + self._connect_control_via_server(timeout_s=timeout_s) + return + except RuntimeError: + if self.control_mode_requested != "auto": + raise + + self._connect_control_via_client(timeout_s=timeout_s) + + def _connect_control_via_server(self, timeout_s: float) -> None: + deadline = time.monotonic() + timeout_s + last_error: Optional[Exception] = None + + while time.monotonic() < deadline: + sock: Optional[socket.socket] = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(1.0) + if self.host_bind != "0.0.0.0": + sock.bind((self.host_bind, 0)) + sock.connect((self.board_ip, self.tcp_server_port)) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + sock.setblocking(False) + self.control_socket = sock + self.control_parser = PacketStreamParser(PACKET_SIZES) + self.active_control_mode = "server" + return + except OSError as exc: + if sock is not None: + sock.close() + last_error = exc + time.sleep(0.2) + + raise RuntimeError(f"Cannot connect to board TCP server: {last_error}") + + def _connect_control_via_client(self, timeout_s: float) -> None: + if self.control_listener_socket is None: + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((self.host_bind, self.tcp_client_port)) + listener.listen(2) + listener.settimeout(0.5) + self.control_listener_socket = listener + + deadline = time.monotonic() + timeout_s + last_error: Optional[Exception] = None + while time.monotonic() < deadline: + try: + conn, _ = self.control_listener_socket.accept() + conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + conn.setblocking(False) + self.control_socket = conn + self.control_parser = PacketStreamParser(PACKET_SIZES) + self.active_control_mode = "client" + return + except socket.timeout: + continue + except OSError as exc: + last_error = exc + time.sleep(0.2) + + if last_error is None: + raise RuntimeError("Cannot accept board TCP client control stream: timed out waiting for connection") + raise RuntimeError(f"Cannot accept board TCP client control stream: {last_error}") + + def close_control(self) -> None: + if self.control_socket is not None: + try: + self.control_socket.close() + finally: + self.control_socket = None + if self.control_listener_socket is not None: + try: + self.control_listener_socket.close() + finally: + self.control_listener_socket = None + + def _send_control_packet(self, packet: bytes, timeout_s: float = 3.0) -> None: + if self.control_socket is None: + raise RuntimeError("Control socket is not connected") + + view = memoryview(packet) + deadline = time.monotonic() + timeout_s + + while len(view) > 0: + try: + sent = self.control_socket.send(view) + if sent <= 0: + raise ConnectionError("Control socket send returned 0 bytes") + view = view[sent:] + except (BlockingIOError, InterruptedError): + if time.monotonic() >= deadline: + raise TimeoutError("Timeout sending packet to board control socket") + select.select([], [self.control_socket], [], 0.05) + except socket.timeout: + if time.monotonic() >= deadline: + raise TimeoutError("Timeout sending packet to board control socket") + + def send_command(self, opcode: int, arg0: int = 0, arg1: int = 0) -> None: + if self.control_socket is None: + raise RuntimeError("Control socket is not connected") + packet = struct.pack(CMD_FMT, TCPIP_CMD_ORDER_ID, opcode, arg0, arg1) + self._send_control_packet(packet) + + def send_payload(self, sequence: int, bad_checksum: bool = False) -> None: + if self.control_socket is None: + raise RuntimeError("Control socket is not connected") + + payload = build_pattern(sequence, 128) + checksum = checksum32(payload) + if bad_checksum: + checksum ^= 0xA5A5A5A5 + + packet = struct.pack(TCP_PAYLOAD_FMT, TCPIP_PAYLOAD_ORDER_ID, sequence, checksum, payload) + self._send_control_packet(packet) + + def _recv_control_packets(self, timeout_s: float) -> List[Tuple[int, bytes]]: + if self.control_socket is None: + raise RuntimeError("Control socket is not connected") + + packets: List[Tuple[int, bytes]] = [] + ready, _, _ = select.select([self.control_socket], [], [], timeout_s) + if not ready: + return packets + + while True: + try: + data = self.control_socket.recv(4096) + except (BlockingIOError, InterruptedError): + break + if not data: + raise ConnectionError("Control socket closed by peer") + + packets.extend(self.control_parser.feed(data)) + ready, _, _ = select.select([self.control_socket], [], [], 0.0) + if not ready: + break + return packets + + def wait_response_matching( + self, + response_code: int, + timeout_s: float = 4.0, + expected_value0: Optional[int] = None, + expected_value1: Optional[int] = None, + expected_value2: Optional[int] = None, + ) -> ResponsePacket: + deadline = time.monotonic() + timeout_s + mismatch_count = 0 + while time.monotonic() < deadline: + packets = self._recv_control_packets(timeout_s=0.2) + for packet_id, packet in packets: + if packet_id == TCPIP_SERVER_STREAM_ORDER_ID: + self.server_stream_packets += 1 + continue + if packet_id == TCPIP_CLIENT_STREAM_ORDER_ID: + self.client_stream_packets += 1 + continue + if packet_id != TCPIP_RESPONSE_ORDER_ID: + continue + + _, code, value0, value1, value2 = struct.unpack(RESP_FMT, packet) + if code != response_code: + continue + if expected_value0 is not None and value0 != expected_value0: + mismatch_count += 1 + continue + if expected_value1 is not None and value1 != expected_value1: + mismatch_count += 1 + continue + if expected_value2 is not None and value2 != expected_value2: + mismatch_count += 1 + continue + + return ResponsePacket(code, value0, value1, value2) + + mismatch_note = f" (mismatched={mismatch_count})" if mismatch_count > 0 else "" + raise TimeoutError(f"Timeout waiting response code {response_code}{mismatch_note}") + + def wait_response(self, response_code: int, timeout_s: float = 4.0) -> ResponsePacket: + return self.wait_response_matching(response_code=response_code, timeout_s=timeout_s) + + def get_health_pages(self, page_count: int = 6) -> Dict[int, Tuple[int, int, int]]: + pages: Dict[int, Tuple[int, int, int]] = {} + for page in range(max(0, page_count)): + self.send_command(CMD_GET_HEALTH, page, 0) + response = self.wait_response(CMD_GET_HEALTH, timeout_s=2.5) + pages[page] = (response.value0, response.value1, response.value2) + return pages + + def collect_server_stream_packets(self, duration_s: float) -> int: + before = self.server_stream_packets + deadline = time.monotonic() + duration_s + while time.monotonic() < deadline: + try: + packets = self._recv_control_packets(timeout_s=0.1) + except ConnectionError: + break + for packet_id, _ in packets: + if packet_id == TCPIP_SERVER_STREAM_ORDER_ID: + self.server_stream_packets += 1 + elif packet_id == TCPIP_CLIENT_STREAM_ORDER_ID: + self.client_stream_packets += 1 + return self.server_stream_packets - before + + def collect_client_stream_packets(self, duration_s: float) -> int: + before = self.client_stream_packets + deadline = time.monotonic() + duration_s + while time.monotonic() < deadline: + try: + packets = self._recv_control_packets(timeout_s=0.1) + except ConnectionError: + break + for packet_id, _ in packets: + if packet_id == TCPIP_CLIENT_STREAM_ORDER_ID: + self.client_stream_packets += 1 + elif packet_id == TCPIP_SERVER_STREAM_ORDER_ID: + self.server_stream_packets += 1 + return self.client_stream_packets - before + + def test_ping(self) -> str: + nonce = random.randint(1, 0xFFFFFFFE) + self.send_command(CMD_PING, nonce, 0) + response = self.wait_response_matching( + CMD_PING, + timeout_s=4.0, + expected_value0=nonce, + ) + if response.value0 != nonce: + raise AssertionError(f"Ping nonce mismatch: expected {nonce}, got {response.value0}") + return f"nonce={nonce} connections={response.value1} payload_rx={response.value2}" + + def test_payload_integrity( + self, + good_count: int, + bad_count: int, + payload_interval_s: float = 0.0, + min_payload_rx_ratio: float = 0.9, + min_bad_detect_ratio: float = 0.8, + ) -> str: + self.send_command(CMD_RESET) + self.wait_response(CMD_RESET) + + for sequence in range(1, good_count + 1): + self.send_payload(sequence, bad_checksum=False) + if payload_interval_s > 0.0: + time.sleep(payload_interval_s) + + for sequence in range(good_count + 1, good_count + bad_count + 1): + self.send_payload(sequence, bad_checksum=True) + if payload_interval_s > 0.0: + time.sleep(payload_interval_s) + + total_sent = good_count + bad_count + min_total_rx = int(total_sent * min_payload_rx_ratio) + min_bad_detected = int(bad_count * min_bad_detect_ratio) + response = ResponsePacket(CMD_GET_STATS, 0, 0, 0) + settle_deadline = time.monotonic() + 3.0 + while True: + self.send_command(CMD_GET_STATS) + response = self.wait_response(CMD_GET_STATS) + enough_rx = response.value0 >= min_total_rx + enough_bad = bad_count == 0 or response.value1 >= min_bad_detected + if enough_rx and enough_bad: + break + if time.monotonic() >= settle_deadline: + break + time.sleep(0.05) + + if response.value0 < min_total_rx: + raise AssertionError( + "Too many missing payloads: " + f"sent={total_sent}, board_count={response.value0}, min_required={min_total_rx}" + ) + if bad_count > 0 and response.value1 < min_bad_detected: + raise AssertionError( + "Bad checksum detection too low: " + f"expected~{bad_count}, board_bad={response.value1}, min_required={min_bad_detected}" + ) + + return ( + f"payload_rx={response.value0} bad_detected={response.value1} " + f"forced_disconnects={response.value2}" + ) + + def test_forced_disconnect_and_reconnect(self) -> str: + self.send_command(CMD_FORCE_DISCONNECT) + + deadline = time.monotonic() + 4.0 + disconnected = False + while time.monotonic() < deadline: + try: + _ = self._recv_control_packets(timeout_s=0.2) + except ConnectionError: + disconnected = True + break + + if not disconnected: + raise AssertionError("Control socket did not disconnect after CMD_FORCE_DISCONNECT") + + self.close_control() + self.connect_control(timeout_s=8.0) + + # Validate that command path still works after reconnect. + nonce = random.randint(1, 0xFFFFFFFE) + self.send_command(CMD_PING, nonce, 0) + response = self.wait_response_matching( + CMD_PING, + timeout_s=4.0, + expected_value0=nonce, + ) + if response.value0 != nonce: + raise AssertionError("Reconnect ping failed") + + return "disconnect+reconnect OK" + + def test_server_burst(self, burst_count: int, min_receive_ratio: float = 0.95) -> str: + last_reason = "unknown" + min_packets_required = int(burst_count * min_receive_ratio) + if burst_count > 0 and min_packets_required < 1: + min_packets_required = 1 + collect_window_s = max(4.0, min(10.0, burst_count / 400.0)) + for attempt in range(1, 4): + before = self.server_stream_packets + try: + self.send_command(CMD_BURST_SERVER, burst_count, 0) + self.wait_response_matching( + CMD_BURST_SERVER, + timeout_s=5.0, + expected_value0=burst_count, + ) + except Exception as exc: # noqa: BLE001 + last_reason = f"attempt={attempt} command/response failed: {exc}" + self.close_control() + try: + self.connect_control(timeout_s=6.0) + except Exception as reconnect_exc: # noqa: BLE001 + last_reason = f"{last_reason}; reconnect failed: {reconnect_exc}" + time.sleep(0.2) + continue + + _ = self.collect_server_stream_packets(duration_s=collect_window_s) + received_total = self.server_stream_packets - before + if received_total >= min_packets_required: + return ( + f"requested={burst_count} received={received_total} " + f"min_required={min_packets_required}" + ) + + last_reason = ( + f"attempt={attempt} received_total={received_total}, " + f"min_required={min_packets_required}" + ) + time.sleep(0.2) + + raise AssertionError(f"TCP server burst below threshold ({last_reason})") + + def test_udp_roundtrip(self, udp_count: int) -> str: + udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + udp_sock.bind((self.host_bind, self.udp_remote_port)) + udp_sock.settimeout(0.05) + + received_sequences = set() + last_ok = 0 + last_bad = 0 + + try: + for sequence in range(1, udp_count + 1): + payload = build_pattern(sequence, 128) + checksum = checksum32(payload) + packet = struct.pack( + UDP_PROBE_FMT, + TCPIP_UDP_PROBE_PACKET_ID, + sequence, + checksum, + payload, + ) + udp_sock.sendto(packet, (self.board_ip, self.udp_local_port)) + + try: + data, _ = udp_sock.recvfrom(256) + except socket.timeout: + continue + + if len(data) == struct.calcsize(UDP_STATUS_FMT): + packet_id, ack_sequence, ack_ok, ack_bad = struct.unpack(UDP_STATUS_FMT, data) + if packet_id == TCPIP_UDP_STATUS_PACKET_ID: + received_sequences.add(ack_sequence) + last_ok = ack_ok + last_bad = ack_bad + + deadline = time.monotonic() + 1.5 + while time.monotonic() < deadline: + try: + data, _ = udp_sock.recvfrom(256) + except socket.timeout: + continue + + if len(data) == struct.calcsize(UDP_STATUS_FMT): + packet_id, ack_sequence, ack_ok, ack_bad = struct.unpack(UDP_STATUS_FMT, data) + if packet_id == TCPIP_UDP_STATUS_PACKET_ID: + received_sequences.add(ack_sequence) + last_ok = ack_ok + last_bad = ack_bad + finally: + udp_sock.close() + + ratio = len(received_sequences) / max(1, udp_count) + if ratio < 0.7: + raise AssertionError( + f"UDP response ratio too low: received={len(received_sequences)} sent={udp_count}" + ) + + return f"responses={len(received_sequences)}/{udp_count} board_ok={last_ok} board_bad={last_bad}" + + +def run_test(name: str, fn) -> Tuple[bool, str]: + start = time.monotonic() + try: + details = fn() + elapsed = time.monotonic() - start + return True, f"{details} ({elapsed:.2f}s)" + except Exception as exc: # noqa: BLE001 + elapsed = time.monotonic() - start + return False, f"{exc} ({elapsed:.2f}s)" + + +def print_health_snapshot(tester: ExampleTcpIpTester, label: str, page_count: int) -> None: + try: + pages = tester.get_health_pages(page_count=page_count) + except Exception as exc: # noqa: BLE001 + print(f"[HEALTH] {label}: unavailable ({exc})") + return + + for page, values in pages.items(): + value0, value1, value2 = values + print(f"[HEALTH] {label} page={page}: {value0},{value1},{value2}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Stress tests for ExampleTCPIP firmware") + parser.add_argument("--board-ip", default="192.168.1.7", help="Board IPv4 address") + parser.add_argument("--host-bind", default="0.0.0.0", help="Host bind address for TCP/UDP sockets") + parser.add_argument("--tcp-server-port", type=int, default=40000, help="Board TCP server port") + parser.add_argument("--tcp-client-port", type=int, default=40002, help="Host TCP sink port") + parser.add_argument( + "--control-mode", + choices=("auto", "server", "client"), + default="auto", + help="Control path mode: try board TCP server, force board TCP server, or force board TCP client stream", + ) + parser.add_argument("--udp-local-port", type=int, default=40003, help="Board UDP local port") + parser.add_argument("--udp-remote-port", type=int, default=40004, help="Host UDP source port") + parser.add_argument("--good-payloads", type=int, default=1200, help="Number of valid TCP payload packets") + parser.add_argument("--bad-payloads", type=int, default=200, help="Number of invalid TCP payload packets") + parser.add_argument( + "--min-payload-rx-ratio", + type=float, + default=0.9, + help="Minimum accepted ratio board_payload_rx / total_payload_sent", + ) + parser.add_argument( + "--min-bad-detect-ratio", + type=float, + default=0.8, + help="Minimum accepted ratio board_bad_detected / bad_payloads", + ) + parser.add_argument( + "--payload-interval-us", + type=int, + default=800, + help="Delay in microseconds between payload packets to avoid RX overruns", + ) + parser.add_argument("--server-burst", type=int, default=800, help="TCP server burst size request") + parser.add_argument("--client-burst", type=int, default=800, help="TCP client burst size request") + parser.add_argument( + "--min-server-burst-ratio", + type=float, + default=0.95, + help="Minimum accepted ratio received_server_packets / requested_server_burst", + ) + parser.add_argument( + "--min-client-burst-ratio", + type=float, + default=0.95, + help="Minimum accepted ratio received_client_packets / requested_client_burst", + ) + parser.add_argument("--udp-count", type=int, default=300, help="UDP probe packet count") + parser.add_argument( + "--health-pages", + type=int, + default=6, + help="Number of health telemetry pages to query from board", + ) + parser.add_argument( + "--reset-health", + action="store_true", + help="Reset board health telemetry counters before running tests", + ) + parser.add_argument( + "--health-on-fail", + dest="health_on_fail", + action="store_true", + default=True, + help="Print board health pages when a test fails (default)", + ) + parser.add_argument( + "--no-health-on-fail", + dest="health_on_fail", + action="store_false", + help="Disable board health dump after failed tests", + ) + parser.add_argument( + "--health-at-end", + action="store_true", + help="Always print board health pages at end of run", + ) + parser.add_argument( + "--skip-client-stream", + action="store_true", + help="Skip verification of board TCP client stream", + ) + parser.add_argument( + "--strict-client-stream", + action="store_true", + help="Fail the run if tcp_client_stream check is unstable", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + for ratio_name in ( + "min_payload_rx_ratio", + "min_bad_detect_ratio", + "min_server_burst_ratio", + "min_client_burst_ratio", + ): + ratio_value = getattr(args, ratio_name) + if ratio_value < 0.0 or ratio_value > 1.0: + raise ValueError(f"{ratio_name} must be in [0.0, 1.0], got {ratio_value}") + + tester = ExampleTcpIpTester( + board_ip=args.board_ip, + tcp_server_port=args.tcp_server_port, + tcp_client_port=args.tcp_client_port, + udp_local_port=args.udp_local_port, + udp_remote_port=args.udp_remote_port, + host_bind=args.host_bind, + control_mode=args.control_mode, + ) + sink: Optional[TcpClientSink] = None + + try: + control_timeout_s = 20.0 if args.control_mode == "client" else 10.0 + tester.connect_control(timeout_s=control_timeout_s) + print(f"[INFO] control_mode_active={tester.active_control_mode}") + + if tester.active_control_mode == "server": + sink = TcpClientSink(bind_ip=args.host_bind, port=args.tcp_client_port) + sink.start() + + if args.reset_health: + tester.send_command(CMD_RESET_HEALTH, 0, 0) + tester.wait_response(CMD_RESET_HEALTH, timeout_s=2.5) + + test_results: List[Tuple[str, bool, str]] = [] + + base_tests: List[Tuple[str, object]] = [ + ("ping", tester.test_ping), + ( + "tcp_payload_integrity", + lambda: tester.test_payload_integrity( + args.good_payloads, + args.bad_payloads, + payload_interval_s=max(0.0, args.payload_interval_us / 1_000_000.0), + min_payload_rx_ratio=args.min_payload_rx_ratio, + min_bad_detect_ratio=args.min_bad_detect_ratio, + ), + ), + ] + + if tester.active_control_mode == "server": + base_tests.extend( + [ + ("forced_disconnect_reconnect", tester.test_forced_disconnect_and_reconnect), + ( + "tcp_server_burst", + lambda: tester.test_server_burst( + args.server_burst, + min_receive_ratio=args.min_server_burst_ratio, + ), + ), + ("udp_roundtrip", lambda: tester.test_udp_roundtrip(args.udp_count)), + ] + ) + else: + print("[INFO] skipping forced_disconnect_reconnect/tcp_server_burst/udp_roundtrip in client mode") + + for name, fn in base_tests: + ok, details = run_test(name, fn) + test_results.append((name, ok, details)) + state = "PASS" if ok else "FAIL" + print(f"[{state}] {name}: {details}") + if not ok and args.health_on_fail: + print_health_snapshot(tester, f"after {name}", page_count=args.health_pages) + + if not args.skip_client_stream: + # Ask board to push data through its Socket() client. + ok, details = run_test( + "tcp_client_stream", + lambda: _run_client_stream_check( + tester, + sink, + args.client_burst, + strict_client_stream=args.strict_client_stream, + min_receive_ratio=args.min_client_burst_ratio, + ) + if tester.active_control_mode == "server" + else _run_client_stream_check_via_control( + tester, + args.client_burst, + strict_client_stream=args.strict_client_stream, + min_receive_ratio=args.min_client_burst_ratio, + ), + ) + if ok: + test_results.append(("tcp_client_stream", True, details)) + print(f"[PASS] tcp_client_stream: {details}") + elif args.strict_client_stream: + test_results.append(("tcp_client_stream", False, details)) + print(f"[FAIL] tcp_client_stream: {details}") + if args.health_on_fail: + print_health_snapshot( + tester, + "after tcp_client_stream", + page_count=args.health_pages, + ) + else: + test_results.append(("tcp_client_stream", True, f"non-strict warning: {details}")) + print(f"[WARN] tcp_client_stream: {details}") + if args.health_on_fail: + print_health_snapshot( + tester, + "after tcp_client_stream_warn", + page_count=args.health_pages, + ) + + failures = [name for name, ok, _ in test_results if not ok] + if args.health_at_end: + print_health_snapshot(tester, "final", page_count=args.health_pages) + print() + if failures: + print(f"Overall: FAIL ({len(failures)} failed) -> {', '.join(failures)}") + return 1 + + print(f"Overall: PASS ({len(test_results)} tests)") + return 0 + + finally: + try: + if tester.active_control_mode == "client" and tester.control_socket is not None: + tester.send_command(CMD_FORCE_CLIENT_RECONNECT, 0, 0) + tester.wait_response_matching( + CMD_FORCE_CLIENT_RECONNECT, + timeout_s=2.0, + expected_value0=1, + ) + except Exception: + pass + tester.close_control() + if sink is not None: + sink.stop() + sink.join(timeout=2.0) + + +def _run_client_stream_check_via_control( + tester: ExampleTcpIpTester, + requested_burst: int, + strict_client_stream: bool = False, + min_receive_ratio: float = 0.95, +) -> str: + min_packets_required = int(requested_burst * min_receive_ratio) + if requested_burst > 0 and min_packets_required < 1: + min_packets_required = 1 + collect_window_s = max(8.0, min(14.0, requested_burst / 300.0)) + + last_reason = "unknown" + for attempt in range(1, 5): + try: + nonce = random.randint(1, 0xFFFFFFFE) + tester.send_command(CMD_PING, nonce, 0) + response = tester.wait_response_matching( + CMD_PING, + timeout_s=3.0, + expected_value0=nonce, + ) + if response.value0 != nonce: + raise AssertionError("ping nonce mismatch before client burst") + except Exception as exc: # noqa: BLE001 + last_reason = f"attempt={attempt} ping/control failed: {exc}" + try: + tester.close_control() + tester.connect_control(timeout_s=20.0) + except Exception as reconnect_exc: # noqa: BLE001 + last_reason = f"{last_reason}; reconnect failed: {reconnect_exc}" + continue + + tester.collect_client_stream_packets(duration_s=0.5) + baseline_packets = tester.client_stream_packets + + try: + tester.send_command(CMD_BURST_CLIENT, requested_burst, 0) + tester.wait_response_matching( + CMD_BURST_CLIENT, + timeout_s=6.0, + expected_value0=requested_burst, + ) + except Exception as exc: # noqa: BLE001 + last_reason = f"attempt={attempt} burst command/ack failed: {exc}" + try: + tester.close_control() + tester.connect_control(timeout_s=20.0) + except Exception as reconnect_exc: # noqa: BLE001 + last_reason = f"{last_reason}; reconnect failed: {reconnect_exc}" + continue + + tester.collect_client_stream_packets(duration_s=collect_window_s) + delta_packets = tester.client_stream_packets - baseline_packets + if delta_packets >= min_packets_required: + return ( + f"requested={requested_burst} received={delta_packets} " + f"min_required={min_packets_required}" + ) + + if delta_packets > 0 and not strict_client_stream: + return ( + f"requested={requested_burst} received={delta_packets} " + f"(fallback_pass_below_threshold min_required={min_packets_required})" + ) + + last_reason = ( + f"attempt={attempt} below threshold: received={delta_packets}, " + f"min_required={min_packets_required}" + ) + try: + tester.close_control() + tester.connect_control(timeout_s=20.0) + except Exception: + pass + + raise AssertionError( + "tcp_client_stream below threshold in client-control mode: " + f"{last_reason}" + ) + + +def _run_client_stream_check( + tester: ExampleTcpIpTester, + sink: Optional[TcpClientSink], + requested_burst: int, + strict_client_stream: bool = False, + min_receive_ratio: float = 0.95, +) -> str: + if sink is None: + raise AssertionError("tcp_client_stream check requires sink in server-control mode") + min_packets_required = int(requested_burst * min_receive_ratio) + if requested_burst > 0 and min_packets_required < 1: + min_packets_required = 1 + measurement_window_s = max(10.0, min(16.0, requested_burst / 250.0)) + _, _, function_start_packets, _, _ = sink.snapshot() + + def ensure_control_ready() -> None: + for _ in range(3): + nonce = random.randint(1, 0xFFFFFFFE) + try: + tester.send_command(CMD_PING, nonce, 0) + response = tester.wait_response_matching( + CMD_PING, + timeout_s=2.5, + expected_value0=nonce, + ) + if response.value0 == nonce: + return + except Exception: # noqa: BLE001 + pass + + tester.close_control() + tester.connect_control(timeout_s=6.0) + raise AssertionError("Control channel is unstable during tcp_client_stream") + + last_reason = "unknown" + for attempt in range(1, 6): + sink.clear_error() + + try: + ensure_control_ready() + except Exception as exc: # noqa: BLE001 + last_reason = f"control check failed: {exc}" + continue + + connections_seen_start, active_start, _, sink_error, _ = sink.snapshot() + if sink_error: + last_reason = f"sink error before burst: {sink_error}" + continue + + if active_start == 0: + try: + tester.send_command(CMD_BURST_CLIENT, 1, 0) + tester.wait_response_matching( + CMD_BURST_CLIENT, + timeout_s=4.0, + expected_value0=1, + ) + except Exception as exc: # noqa: BLE001 + last_reason = f"connection nudge failed: {exc}" + continue + + wait_deadline = time.monotonic() + 12.0 + connected = False + while time.monotonic() < wait_deadline: + conn_seen_now, active_now, _, sink_error, _ = sink.snapshot() + if sink_error: + break + if active_now > 0 or conn_seen_now > connections_seen_start: + connected = True + break + time.sleep(0.05) + if not connected: + last_reason = "board client stream did not connect to sink in time" + if sink_error: + last_reason = f"{last_reason}; sink_error={sink_error}" + continue + + time.sleep(0.1) + _, _, base_packets, sink_error, _ = sink.snapshot() + if sink_error: + last_reason = f"sink error before measurement: {sink_error}" + continue + + try: + tester.send_command(CMD_BURST_CLIENT, requested_burst, 0) + tester.wait_response_matching( + CMD_BURST_CLIENT, + timeout_s=6.0, + expected_value0=requested_burst, + ) + except Exception as exc: # noqa: BLE001 + last_reason = f"burst command failed: {exc}" + continue + + connections_seen = connections_seen_start + deadline = time.monotonic() + measurement_window_s + while time.monotonic() < deadline: + connections_seen, active_connections, current_packets, sink_error, _ = sink.snapshot() + if sink_error: + last_reason = f"sink error during burst: {sink_error}" + break + delta_packets = current_packets - base_packets + if delta_packets >= min_packets_required: + return ( + f"requested={requested_burst} received={delta_packets} " + f"min_required={min_packets_required}" + ) + if delta_packets == 0 and active_connections == 0: + # Connection dropped before payload burst reached sink. + last_reason = ( + f"connection dropped during burst (attempt={attempt}, " + f"connections_seen={connections_seen})" + ) + time.sleep(0.1) + + _, _, final_packets, sink_error, _ = sink.snapshot() + if sink_error: + last_reason = f"sink error after burst: {sink_error}" + continue + delta_packets = final_packets - base_packets + last_reason = ( + f"below threshold after burst (attempt={attempt}, delta={delta_packets}, " + f"min_required={min_packets_required}, connections_seen={connections_seen})" + ) + + connections_seen, active_connections, packets, sink_error, _ = sink.snapshot() + overall_delta = packets - function_start_packets + if overall_delta < 0: + overall_delta = 0 + if connections_seen > 0 and overall_delta >= min_packets_required and sink_error is None: + return ( + f"requested={requested_burst} received={overall_delta} " + f"min_required={min_packets_required} (aggregate_window_pass)" + ) + if connections_seen > 0 and packets > 0 and sink_error is None: + if strict_client_stream: + raise AssertionError( + "tcp_client_stream fallback-only result in strict mode: " + f"connections_seen={connections_seen}, packets_seen={packets}, " + f"overall_delta={overall_delta}, min_required={min_packets_required}" + ) + return ( + f"requested={requested_burst} received=0 " + f"(fallback_pass: connections_seen={connections_seen}, packets_seen={packets})" + ) + raise AssertionError( + "tcp_client_stream failed after retries: " + f"{last_reason}; connections_seen={connections_seen}, " + f"active_connections={active_connections}, packets={packets}, sink_error={sink_error}" + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/run_example_tcpip_nucleo.sh b/tools/run_example_tcpip_nucleo.sh new file mode 100755 index 00000000..ce4811d2 --- /dev/null +++ b/tools/run_example_tcpip_nucleo.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +repo_root="$(CDPATH= cd -- "${script_dir}/.." && pwd)" + +iface="en6" +board_ip="192.168.1.7" +host_ip="" +host_bind="0.0.0.0" +preset="nucleo-debug-eth" +jobs=8 +flash_method="stm32prog" +base_runs=1 +aggr_runs=0 +control_mode="auto" +strict_client_stream=1 +health_on_fail=1 +health_at_end=1 +skip_build=0 +skip_flash=0 +skip_ping=0 +skip_tests=0 +skip_localnet_check=0 + +usage() { + cat <<'EOF' +Usage: tools/run_example_tcpip_nucleo.sh [options] + +End-to-end flow: +1) Detect host IP from interface (or use explicit override) +2) Build ExampleTCPIP with TCPIP_TEST_HOST_IP= +3) Flash Nucleo +4) Verify network reachability (ping/arp) +5) Run quality gate matrix + +Options: + --iface Host interface for host IP detection (default: en6) + --board-ip Board IP (default: 192.168.1.7) + --host-ip Explicit host IPv4 (default: auto-detect on iface) + --host-bind Host bind IP for tests (default: 0.0.0.0) + --preset CMake preset (default: nucleo-debug-eth) + --jobs Build parallelism (default: 8) + --flash-method stm32prog|openocd (default: stm32prog) + --base-runs Base quality-gate runs (default: 1) + --aggr-runs Aggressive quality-gate runs (default: 0) + --control-mode auto|server|client (default: auto) + --no-strict-client-stream Disable strict tcp_client_stream checks + --no-health-on-fail Disable health dump on failed tests + --no-health-at-end Disable end-of-run health dump + --skip-build Skip cmake configure/build + --skip-flash Skip flashing step + --skip-ping Skip ping/arp network check + --skip-localnet-check Skip host local-network diagnostics + --skip-tests Skip quality-gate execution + -h, --help Show help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --iface) iface="$2"; shift 2 ;; + --board-ip) board_ip="$2"; shift 2 ;; + --host-ip) host_ip="$2"; shift 2 ;; + --host-bind) host_bind="$2"; shift 2 ;; + --preset) preset="$2"; shift 2 ;; + --jobs) jobs="$2"; shift 2 ;; + --flash-method) flash_method="$2"; shift 2 ;; + --base-runs) base_runs="$2"; shift 2 ;; + --aggr-runs) aggr_runs="$2"; shift 2 ;; + --control-mode) control_mode="$2"; shift 2 ;; + --no-strict-client-stream) strict_client_stream=0; shift ;; + --no-health-on-fail) health_on_fail=0; shift ;; + --no-health-at-end) health_at_end=0; shift ;; + --skip-build) skip_build=1; shift ;; + --skip-flash) skip_flash=1; shift ;; + --skip-ping) skip_ping=1; shift ;; + --skip-localnet-check) skip_localnet_check=1; shift ;; + --skip-tests) skip_tests=1; shift ;; + -h|--help) usage; exit 0 ;; + *) + echo "Unknown option: $1" >&2 + usage + exit 2 + ;; + esac +done + +if [[ -z "${host_ip}" ]]; then + board_prefix="${board_ip%.*}." + while IFS= read -r candidate; do + if [[ "${candidate}" == "${board_prefix}"* ]]; then + host_ip="${candidate}" + break + fi + done < <(ifconfig "${iface}" 2>/dev/null | awk '/inet /{print $2}') +fi + +if [[ -z "${host_ip}" ]]; then + host_ip="$(ipconfig getifaddr "${iface}" || true)" +fi + +if [[ -z "${host_ip}" ]]; then + echo "Could not determine host IPv4 on interface ${iface}" >&2 + echo "Tip: run tools/configure_nucleo_host_network_macos.sh first or pass --host-ip." >&2 + exit 2 +fi + +echo "RUN_CONTEXT iface=${iface} host_ip=${host_ip} board_ip=${board_ip} preset=${preset}" + +effective_host_bind="${host_bind}" +if [[ "${effective_host_bind}" == "0.0.0.0" ]]; then + effective_host_bind="${host_ip}" +fi + +if ! ifconfig "${iface}" 2>/dev/null | rg -Fq "inet ${host_ip} "; then + echo "Interface ${iface} does not currently hold host_ip ${host_ip}" >&2 + exit 2 +fi + +board_route_output="$(route -n get "${board_ip}" 2>/dev/null || true)" +board_route_iface="$(printf '%s\n' "${board_route_output}" | awk '/interface:/{print $2; exit}')" +if [[ "${board_route_iface}" != "${iface}" ]]; then + echo "${board_route_output}" + echo "Board route does not use ${iface} (got: ${board_route_iface:-none})." >&2 + echo "Proceeding because source-binding to ${host_ip} can still reach the board on macOS." >&2 +fi + +if [[ "${skip_localnet_check}" -eq 0 ]]; then + default_gateway="$(route -n get default 2>/dev/null | awk '/gateway:/{print $2; exit}')" + internet_probe="$(ping -c 1 -W 1000 1.1.1.1 || true)" + if printf '%s\n' "${internet_probe}" | rg -q "bytes from 1.1.1.1" && [[ -n "${default_gateway}" ]]; then + local_probe="$(nc -vz -w 2 "${default_gateway}" 80 2>&1 || true)" + if printf '%s\n' "${local_probe}" | rg -q "No route to host"; then + cat >&2 <<'EOF' +Host can reach internet but cannot open local-network routes from this process context. +Likely macOS Local Network privacy block for the app executing this script +(e.g. VS Code / Codex extension host). Enable it in: +System Settings > Privacy & Security > Local Network +EOF + exit 2 + fi + fi +fi + +cd "${repo_root}" + +if [[ "${skip_build}" -eq 0 ]]; then + cmake --preset "${preset}" \ + -DBUILD_EXAMPLES=ON \ + -DCMAKE_CXX_FLAGS="-DEXAMPLE_TCPIP -DTCPIP_TEST_HOST_IP=${host_ip} -DTCPIP_TEST_BOARD_IP=${board_ip}" + cmake --build --preset "${preset}" -j"${jobs}" +fi + +if [[ "${skip_flash}" -eq 0 ]]; then + case "${flash_method}" in + stm32prog) + STM32_Programmer_CLI -c port=SWD mode=UR -w out/build/latest.elf -v -rst + ;; + openocd) + if ! openocd -f .vscode/stlink.cfg -f .vscode/stm32h7x.cfg \ + -c "program out/build/latest.elf verify reset exit"; then + echo "OpenOCD verify failed, retrying flash without verify (RAM sections may cause false mismatch)." >&2 + openocd -f .vscode/stlink.cfg -f .vscode/stm32h7x.cfg \ + -c "program out/build/latest.elf reset exit" + fi + ;; + *) + echo "Unsupported flash method: ${flash_method}" >&2 + exit 2 + ;; + esac +fi + +if [[ "${skip_ping}" -eq 0 ]]; then + ping_output="$(ping -S "${host_ip}" -c 5 "${board_ip}" || true)" + echo "${ping_output}" + echo "---" + arp -a | rg -F "${board_ip}" || true + if ! printf '%s\n' "${ping_output}" | rg -Fq "bytes from ${board_ip}"; then + if printf '%s\n' "${ping_output}" | rg -Fq "No route to host"; then + echo "ICMP failed with 'No route to host'." >&2 + echo "Route diagnostics:" >&2 + route -n get "${board_ip}" >&2 || true + echo "NWI diagnostics:" >&2 + scutil --nwi >&2 || true + fi + echo "Board is not reachable over ICMP after flash (board_ip=${board_ip})" >&2 + exit 3 + fi +fi + +if [[ "${skip_tests}" -eq 0 ]]; then + cmd=( + ./tools/example_tcpip_quality_gate.sh + --board-ip "${board_ip}" + --host-bind "${effective_host_bind}" + --base-runs "${base_runs}" + --aggr-runs "${aggr_runs}" + --control-mode "${control_mode}" + ) + if [[ "${strict_client_stream}" -eq 0 ]]; then + cmd+=(--no-strict-client-stream) + fi + if [[ "${health_on_fail}" -eq 0 ]]; then + cmd+=(--no-health-on-fail) + fi + if [[ "${health_at_end}" -eq 1 ]]; then + cmd+=(--health-at-end) + fi + + "${cmd[@]}" +fi diff --git a/tools/run_example_tcpip_stress.sh b/tools/run_example_tcpip_stress.sh new file mode 100755 index 00000000..0c094e9d --- /dev/null +++ b/tools/run_example_tcpip_stress.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +repo_root="$(CDPATH= cd -- "${script_dir}/.." && pwd)" + +cd "${repo_root}" +python3 tools/example_tcpip_stress.py "$@" From 8f51aa12b0a75085efb748c883b11f575fdba185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1ez?= Date: Sat, 28 Feb 2026 13:02:22 +0100 Subject: [PATCH 03/11] feat(packets): add generated packet parsing example for TEST board --- Core/Inc/Code_generation/JSON_ADE | 2 +- .../Communications/Packets/DataPackets.hpp | 177 +++++++ .../Communications/Packets/OrderPackets.hpp | 109 ++++ Core/Src/Examples/ExamplePackets.cpp | 233 ++++++++ tools/example_packets_check.py | 500 ++++++++++++++++++ 5 files changed, 1020 insertions(+), 1 deletion(-) create mode 100644 Core/Inc/Communications/Packets/DataPackets.hpp create mode 100644 Core/Inc/Communications/Packets/OrderPackets.hpp create mode 100644 Core/Src/Examples/ExamplePackets.cpp create mode 100755 tools/example_packets_check.py diff --git a/Core/Inc/Code_generation/JSON_ADE b/Core/Inc/Code_generation/JSON_ADE index ee857f81..5b856377 160000 --- a/Core/Inc/Code_generation/JSON_ADE +++ b/Core/Inc/Code_generation/JSON_ADE @@ -1 +1 @@ -Subproject commit ee857f81e0aef674a19f8fc5db4a2d3c28a75a51 +Subproject commit 5b856377fb0fb3ca67bc4467598cec986116fe22 diff --git a/Core/Inc/Communications/Packets/DataPackets.hpp b/Core/Inc/Communications/Packets/DataPackets.hpp new file mode 100644 index 00000000..e7fe74cd --- /dev/null +++ b/Core/Inc/Communications/Packets/DataPackets.hpp @@ -0,0 +1,177 @@ +#pragma once +#include "ST-LIB.hpp" + +/*Data packets for TEST +-AUTOGENERATED CODE, DO NOT MODIFY-*/ +class DataPackets { +public: + enum class mirror_mode : uint8_t { + IDLE = 0, + RUN = 1, + SAFE = 2, + CAL = 3, + }; + enum class mirror_state : uint8_t { + BOOT = 0, + ARMED = 1, + STREAMING = 2, + ERROR = 3, + }; + enum class probe_mode : uint8_t { + LOW = 0, + MEDIUM = 1, + HIGH = 2, + TURBO = 3, + }; + + static void order_mirror_init( + uint32_t& tcp_order_count, + uint16_t& last_order_code, + bool& enable_flag, + uint8_t& small_counter, + int16_t& offset_value, + mirror_mode& mirror_mode + ) { + order_mirror_packet = new HeapPacket( + static_cast(20737), + &tcp_order_count, + &last_order_code, + &enable_flag, + &small_counter, + &offset_value, + &mirror_mode + ); + } + + static void numeric_mirror_init( + uint16_t& window_size, + uint32_t& magic_value, + int32_t& position_value, + float& ratio_value, + double& precise_value + ) { + numeric_mirror_packet = new HeapPacket( + static_cast(20738), + &window_size, + &magic_value, + &position_value, + &ratio_value, + &precise_value + ); + } + + static void extremes_mirror_init( + int8_t& trim_value, + int64_t& energy_value, + uint64_t& big_counter, + mirror_state& mirror_state + ) { + extremes_mirror_packet = new HeapPacket( + static_cast(20739), + &trim_value, + &energy_value, + &big_counter, + &mirror_state + ); + } + + static void udp_probe_init( + uint32_t& probe_seq, + bool& probe_toggle, + uint16_t& probe_window, + float& probe_ratio, + probe_mode& probe_mode + ) { + udp_probe_packet = new HeapPacket( + static_cast(20740), + &probe_seq, + &probe_toggle, + &probe_window, + &probe_ratio, + &probe_mode + ); + } + + static void udp_probe_echo_init( + uint32_t& udp_parse_count, + uint32_t& probe_seq, + bool& probe_toggle, + uint16_t& probe_window, + float& probe_ratio, + probe_mode& probe_mode + ) { + udp_probe_echo_packet = new HeapPacket( + static_cast(20741), + &udp_parse_count, + &probe_seq, + &probe_toggle, + &probe_window, + &probe_ratio, + &probe_mode + ); + } + + static void heartbeat_snapshot_init( + uint32_t& heartbeat_ticks, + uint32_t& tcp_order_count, + uint32_t& udp_parse_count, + mirror_state& mirror_state + ) { + heartbeat_snapshot_packet = new HeapPacket( + static_cast(20742), + &heartbeat_ticks, + &tcp_order_count, + &udp_parse_count, + &mirror_state + ); + } + +public: + inline static HeapPacket* order_mirror_packet{nullptr}; + inline static HeapPacket* numeric_mirror_packet{nullptr}; + inline static HeapPacket* extremes_mirror_packet{nullptr}; + inline static HeapPacket* udp_probe_packet{nullptr}; + inline static HeapPacket* udp_probe_echo_packet{nullptr}; + inline static HeapPacket* heartbeat_snapshot_packet{nullptr}; + + inline static DatagramSocket* telemetry_udp{nullptr}; + + static void start() { + if (order_mirror_packet == nullptr) { + ErrorHandler("Packet order_mirror not initialized"); + } + if (numeric_mirror_packet == nullptr) { + ErrorHandler("Packet numeric_mirror not initialized"); + } + if (extremes_mirror_packet == nullptr) { + ErrorHandler("Packet extremes_mirror not initialized"); + } + if (udp_probe_packet == nullptr) { + ErrorHandler("Packet udp_probe not initialized"); + } + if (udp_probe_echo_packet == nullptr) { + ErrorHandler("Packet udp_probe_echo not initialized"); + } + if (heartbeat_snapshot_packet == nullptr) { + ErrorHandler("Packet heartbeat_snapshot not initialized"); + } + + telemetry_udp = new DatagramSocket("192.168.1.7", 41001, "192.168.1.9", 41001); + + Scheduler::register_task( + 50000, + +[]() { + DataPackets::telemetry_udp->send_packet(*DataPackets::order_mirror_packet); + DataPackets::telemetry_udp->send_packet(*DataPackets::numeric_mirror_packet); + DataPackets::telemetry_udp->send_packet(*DataPackets::extremes_mirror_packet); + DataPackets::telemetry_udp->send_packet(*DataPackets::udp_probe_echo_packet); + } + ); + Scheduler::register_task( + 250000, + +[]() { + DataPackets::telemetry_udp->send_packet(*DataPackets::heartbeat_snapshot_packet); + } + ); + } +}; diff --git a/Core/Inc/Communications/Packets/OrderPackets.hpp b/Core/Inc/Communications/Packets/OrderPackets.hpp new file mode 100644 index 00000000..1a0ac6cf --- /dev/null +++ b/Core/Inc/Communications/Packets/OrderPackets.hpp @@ -0,0 +1,109 @@ +#pragma once +#include "ST-LIB.hpp" + +/*Order packets for TEST +-AUTOGENERATED CODE, DO NOT MODIFY- */ + +class OrderPackets { +public: + enum class order_mode : uint8_t { + IDLE = 0, + RUN = 1, + SAFE = 2, + CAL = 3, + }; + enum class order_state : uint8_t { + BOOT = 0, + ARMED = 1, + STREAMING = 2, + ERROR = 3, + }; + + inline static bool set_small_profile_flag{false}; + inline static bool set_large_profile_flag{false}; + inline static bool set_extremes_flag{false}; + inline static bool bump_state_flag{false}; + inline static bool set_state_code_flag{false}; + + OrderPackets() = default; + + inline static HeapOrder* set_small_profile_order{nullptr}; + inline static HeapOrder* set_large_profile_order{nullptr}; + inline static HeapOrder* set_extremes_order{nullptr}; + inline static HeapOrder* bump_state_order{nullptr}; + inline static HeapOrder* set_state_code_order{nullptr}; + + static void set_small_profile_init( + bool& enable_flag, + uint8_t& small_counter, + int16_t& offset_value, + order_mode& order_mode + ) { + set_small_profile_order = new HeapOrder( + 20481, + &set_small_profile_cb, + &enable_flag, + &small_counter, + &offset_value, + &order_mode + ); + } + static void set_large_profile_init( + uint16_t& window_size, + uint32_t& magic_value, + int32_t& position_value, + float& ratio_value, + double& precise_value + ) { + set_large_profile_order = new HeapOrder( + 20482, + &set_large_profile_cb, + &window_size, + &magic_value, + &position_value, + &ratio_value, + &precise_value + ); + } + static void + set_extremes_init(int8_t& trim_value, int64_t& energy_value, uint64_t& big_counter) { + set_extremes_order = + new HeapOrder(20483, &set_extremes_cb, &trim_value, &energy_value, &big_counter); + } + static void bump_state_init() { bump_state_order = new HeapOrder(20484, &bump_state_cb); } + static void set_state_code_init(order_state& order_state) { + set_state_code_order = new HeapOrder(20485, &set_state_code_cb, &order_state); + } + + inline static Socket* control_test_client{nullptr}; + + inline static ServerSocket* control_test_tcp{nullptr}; + + static void start() { + if (set_small_profile_order == nullptr) { + ErrorHandler("Order set_small_profile not initialized"); + } + if (set_large_profile_order == nullptr) { + ErrorHandler("Order set_large_profile not initialized"); + } + if (set_extremes_order == nullptr) { + ErrorHandler("Order set_extremes not initialized"); + } + if (bump_state_order == nullptr) { + ErrorHandler("Order bump_state not initialized"); + } + if (set_state_code_order == nullptr) { + ErrorHandler("Order set_state_code not initialized"); + } + + control_test_tcp = new ServerSocket("192.168.1.7", 41000); + control_test_client = new Socket("192.168.1.7", 41002, "192.168.1.9", 41003); + } + +private: + static void set_small_profile_cb() { set_small_profile_flag = true; } + static void set_large_profile_cb() { set_large_profile_flag = true; } + static void set_extremes_cb() { set_extremes_flag = true; } + static void bump_state_cb() { bump_state_flag = true; } + static void set_state_code_cb() { set_state_code_flag = true; } +}; diff --git a/Core/Src/Examples/ExamplePackets.cpp b/Core/Src/Examples/ExamplePackets.cpp new file mode 100644 index 00000000..d6e8e56e --- /dev/null +++ b/Core/Src/Examples/ExamplePackets.cpp @@ -0,0 +1,233 @@ +#ifdef EXAMPLE_PACKETS + +#include "Communications/Packets/DataPackets.hpp" +#include "Communications/Packets/OrderPackets.hpp" +#include "ST-LIB.hpp" +#include "main.h" + +using namespace ST_LIB; + +#ifndef STLIB_ETH +#error "EXAMPLE_PACKETS requires STLIB_ETH" +#endif + +constexpr auto led = ST_LIB::DigitalOutputDomain::DigitalOutput(ST_LIB::PB0); + +#if defined(USE_PHY_LAN8742) +constexpr auto eth = EthernetDomain::Ethernet( + EthernetDomain::PINSET_H10, + "00:80:e1:00:01:08", + "192.168.1.7", + "255.255.0.0" +); +#elif defined(USE_PHY_LAN8700) +constexpr auto eth = EthernetDomain::Ethernet( + EthernetDomain::PINSET_H10, + "00:80:e1:00:01:08", + "192.168.1.7", + "255.255.0.0" +); +#elif defined(USE_PHY_KSZ8041) +constexpr auto eth = EthernetDomain::Ethernet( + EthernetDomain::PINSET_H11, + "00:80:e1:00:01:08", + "192.168.1.7", + "255.255.0.0" +); +#else +#error "No PHY selected for Ethernet pinset selection" +#endif + +using ExamplePacketsBoard = ST_LIB::Board; + +namespace { + +constexpr uint16_t ORDER_ID_SET_SMALL_PROFILE = 0x5001; +constexpr uint16_t ORDER_ID_SET_LARGE_PROFILE = 0x5002; +constexpr uint16_t ORDER_ID_SET_EXTREMES = 0x5003; +constexpr uint16_t ORDER_ID_BUMP_STATE = 0x5004; +constexpr uint16_t ORDER_ID_SET_STATE_CODE = 0x5005; + +constexpr uint32_t HEARTBEAT_PERIOD_MS = 250; +constexpr uint32_t SOCKET_RECONNECT_PERIOD_MS = 1000; + +constexpr char BOARD_IP[] = "192.168.1.7"; + +bool enable_flag{false}; +uint8_t small_counter{0}; +uint16_t window_size{0}; +uint32_t magic_value{0}; +uint64_t big_counter{0}; +int8_t trim_value{0}; +int16_t offset_value{0}; +int32_t position_value{0}; +int64_t energy_value{0}; +float ratio_value{0.0f}; +double precise_value{0.0}; + +uint32_t tcp_order_count{0}; +uint32_t udp_parse_count{0}; +uint32_t heartbeat_ticks{0}; +uint16_t last_order_code{0}; + +OrderPackets::order_mode order_mode{OrderPackets::order_mode::IDLE}; +OrderPackets::order_state order_state{OrderPackets::order_state::BOOT}; +DataPackets::mirror_mode mirror_mode{DataPackets::mirror_mode::IDLE}; +DataPackets::mirror_state mirror_state{DataPackets::mirror_state::BOOT}; + +uint32_t probe_seq{0}; +bool probe_toggle{false}; +uint16_t probe_window{0}; +float probe_ratio{0.0f}; +DataPackets::probe_mode probe_mode{DataPackets::probe_mode::LOW}; +uint32_t last_seen_probe_seq{0}; + +void sync_mirror_enums() { + mirror_mode = static_cast(static_cast(order_mode)); + mirror_state = static_cast(static_cast(order_state)); +} + +void process_orders() { + if (OrderPackets::set_small_profile_flag) { + OrderPackets::set_small_profile_flag = false; + ++tcp_order_count; + last_order_code = ORDER_ID_SET_SMALL_PROFILE; + } + + if (OrderPackets::set_large_profile_flag) { + OrderPackets::set_large_profile_flag = false; + ++tcp_order_count; + last_order_code = ORDER_ID_SET_LARGE_PROFILE; + } + + if (OrderPackets::set_extremes_flag) { + OrderPackets::set_extremes_flag = false; + ++tcp_order_count; + last_order_code = ORDER_ID_SET_EXTREMES; + } + + if (OrderPackets::bump_state_flag) { + OrderPackets::bump_state_flag = false; + ++tcp_order_count; + last_order_code = ORDER_ID_BUMP_STATE; + const uint8_t next_state = (static_cast(order_state) + 1U) % 4U; + order_state = static_cast(next_state); + } + + if (OrderPackets::set_state_code_flag) { + OrderPackets::set_state_code_flag = false; + ++tcp_order_count; + last_order_code = ORDER_ID_SET_STATE_CODE; + } + + sync_mirror_enums(); +} + +void process_probe_packet() { + if (probe_seq == last_seen_probe_seq) { + return; + } + last_seen_probe_seq = probe_seq; + ++udp_parse_count; +} + +} // namespace + +int main(void) { + Hard_fault_check(); + ExamplePacketsBoard::init(); + Scheduler::start(); + + OrderPackets::set_small_profile_init(enable_flag, small_counter, offset_value, order_mode); + OrderPackets::set_large_profile_init( + window_size, + magic_value, + position_value, + ratio_value, + precise_value + ); + OrderPackets::set_extremes_init(trim_value, energy_value, big_counter); + OrderPackets::bump_state_init(); + OrderPackets::set_state_code_init(order_state); + + DataPackets::order_mirror_init( + tcp_order_count, + last_order_code, + enable_flag, + small_counter, + offset_value, + mirror_mode + ); + DataPackets::numeric_mirror_init( + window_size, + magic_value, + position_value, + ratio_value, + precise_value + ); + DataPackets::extremes_mirror_init(trim_value, energy_value, big_counter, mirror_state); + DataPackets::udp_probe_init(probe_seq, probe_toggle, probe_window, probe_ratio, probe_mode); + DataPackets::udp_probe_echo_init( + udp_parse_count, + probe_seq, + probe_toggle, + probe_window, + probe_ratio, + probe_mode + ); + DataPackets::heartbeat_snapshot_init( + heartbeat_ticks, + tcp_order_count, + udp_parse_count, + mirror_state + ); + + DataPackets::start(); + OrderPackets::start(); + + auto& eth_instance = ExamplePacketsBoard::instance_of(); + auto& led_instance = ExamplePacketsBoard::instance_of(); + + sync_mirror_enums(); + + uint32_t last_heartbeat_ms = HAL_GetTick(); + uint32_t last_reconnect_ms = HAL_GetTick(); + bool led_state = false; + + while (1) { + eth_instance.update(); + Scheduler::update(); + + process_orders(); + process_probe_packet(); + + const uint32_t now = HAL_GetTick(); + if ((now - last_reconnect_ms) >= SOCKET_RECONNECT_PERIOD_MS) { + last_reconnect_ms = now; + if (OrderPackets::control_test_client != nullptr && + !OrderPackets::control_test_client->is_connected()) { + OrderPackets::control_test_client->reconnect(); + } + if (OrderPackets::control_test_tcp != nullptr && + !OrderPackets::control_test_tcp->is_connected() && + !OrderPackets::control_test_tcp->is_listening()) { + delete OrderPackets::control_test_tcp; + OrderPackets::control_test_tcp = new ServerSocket(BOARD_IP, 41000); + } + } + + if ((now - last_heartbeat_ms) >= HEARTBEAT_PERIOD_MS) { + last_heartbeat_ms = now; + ++heartbeat_ticks; + sync_mirror_enums(); + led_state = !led_state; + if (led_state) { + led_instance.turn_on(); + } else { + led_instance.turn_off(); + } + } + } +} + +#endif // EXAMPLE_PACKETS diff --git a/tools/example_packets_check.py b/tools/example_packets_check.py new file mode 100755 index 00000000..ba41274a --- /dev/null +++ b/tools/example_packets_check.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import math +import random +import socket +import struct +import sys +import time +from pathlib import Path + + +TYPE_LAYOUT = { + "bool": ("?", 1), + "uint8": ("B", 1), + "uint16": ("H", 2), + "uint32": ("I", 4), + "uint64": ("Q", 8), + "int8": ("b", 1), + "int16": ("h", 2), + "int32": ("i", 4), + "int64": ("q", 8), + "float32": ("f", 4), + "float64": ("d", 8), + "enum": ("B", 1), +} + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +class Schema: + def __init__(self, board_name: str): + json_root = repo_root() / "Core/Inc/Code_generation/JSON_ADE" + boards = json.loads((json_root / "boards.json").read_text()) + if board_name not in boards: + raise KeyError(f"Board {board_name} not found in boards.json") + + board_path = json_root / boards[board_name] + self.board = json.loads(board_path.read_text()) + self.board_dir = board_path.parent + self.board_name = board_name + self.board_ip = self.board["board_ip"] + + self.measurements: dict[str, dict] = {} + for measurement_file in self.board["measurements"]: + for measurement in json.loads((self.board_dir / measurement_file).read_text()): + self.measurements[measurement["id"]] = measurement + + self.orders: dict[str, dict] = {} + self.packets: dict[str, dict] = {} + self.packet_by_id: dict[int, dict] = {} + self.order_by_id: dict[int, dict] = {} + + for definition_file in self.board["packets"]: + for definition in json.loads((self.board_dir / definition_file).read_text()): + table = self.orders if definition["type"] == "order" else self.packets + by_id = self.order_by_id if definition["type"] == "order" else self.packet_by_id + table[definition["name"]] = definition + by_id[definition["id"]] = definition + + sockets = json.loads((self.board_dir / "sockets.json").read_text()) + server_sockets = [entry for entry in sockets if entry["type"] == "ServerSocket"] + client_sockets = [entry for entry in sockets if entry["type"] == "Socket"] + datagram_sockets = [entry for entry in sockets if entry["type"] == "DatagramSocket"] + if not server_sockets: + raise ValueError("No ServerSocket defined for TEST board") + self.tcp_server_port = int(server_sockets[0]["port"]) + if not client_sockets: + raise ValueError("No Socket defined for TEST board") + if not datagram_sockets: + raise ValueError("No DatagramSocket defined for TEST board") + self.tcp_client_port = int(client_sockets[0]["remote_port"]) + self.udp_port = int(datagram_sockets[0]["port"]) + + def field_layout(self, field_name: str) -> tuple[str, int]: + measurement = self.measurements[field_name] + return TYPE_LAYOUT[measurement["type"]] + + def packet_size(self, definition: dict) -> int: + total = 2 + for field_name in definition.get("variables", []): + total += self.field_layout(field_name)[1] + return total + + def encode(self, definition: dict, values: dict[str, object]) -> bytes: + payload = bytearray(struct.pack(" tuple[dict, dict[str, object]]: + if len(raw) < 2: + raise ValueError("Frame is too short") + packet_id = struct.unpack_from(" None: + offset = 0 + while offset < len(payload): + chunk = min(rng.randint(1, max_chunk), len(payload) - offset) + conn.sendall(payload[offset : offset + chunk]) + offset += chunk + if offset < len(payload) and max_gap_ms > 0: + time.sleep(rng.uniform(0.0, max_gap_ms / 1000.0)) + + +def values_match(schema: Schema, expected: dict[str, object], actual: dict[str, object]) -> bool: + for field_name, expected_value in expected.items(): + if field_name not in actual: + return False + measurement_type = schema.measurements[field_name]["type"] + actual_value = actual[field_name] + if measurement_type in ("float32", "float64"): + if not math.isclose( + float(actual_value), + float(expected_value), + rel_tol=1e-6, + abs_tol=1e-5, + ): + return False + else: + if actual_value != expected_value: + return False + return True + + +def recv_matching_packet( + udp_sock: socket.socket, + schema: Schema, + packet_name: str, + predicate, + timeout_s: float, +) -> tuple[dict, dict[str, object]]: + deadline = time.monotonic() + timeout_s + last_error: str | None = None + while time.monotonic() < deadline: + udp_sock.settimeout(max(0.1, deadline - time.monotonic())) + try: + raw, _ = udp_sock.recvfrom(2048) + except socket.timeout: + continue + + try: + definition, decoded = schema.decode_packet(raw) + except Exception as exc: # noqa: BLE001 + last_error = str(exc) + continue + + if definition["name"] != packet_name: + continue + if predicate(decoded): + return definition, decoded + last_error = f"{packet_name} received but predicate rejected payload {decoded}" + + if last_error is None: + raise TimeoutError(f"Timed out waiting for {packet_name}") + raise TimeoutError(f"Timed out waiting for {packet_name}: {last_error}") + + +def wait_for_accept(listener: socket.socket, timeout_s: float) -> socket.socket: + listener.settimeout(timeout_s) + conn, _ = listener.accept() + conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + return conn + + +def connect_with_retry(host: str, port: int, timeout_s: float, local_bind: str | None = None) -> socket.socket: + deadline = time.monotonic() + timeout_s + last_error: Exception | None = None + while time.monotonic() < deadline: + conn: socket.socket | None = None + try: + conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + conn.settimeout(1.0) + if local_bind and local_bind != "0.0.0.0": + conn.bind((local_bind, 0)) + conn.connect((host, port)) + conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + return conn + except OSError as exc: + if conn is not None: + conn.close() + last_error = exc + time.sleep(0.25) + raise TimeoutError(f"Timed out connecting to {host}:{port}: {last_error}") + + +def random_small_profile(rng: random.Random) -> dict[str, object]: + return { + "enable_flag": bool(rng.getrandbits(1)), + "small_counter": rng.randint(0, 255), + "offset_value": rng.randint(-32000, 32000), + "order_mode": rng.randint(0, 3), + } + + +def random_large_profile(rng: random.Random) -> dict[str, object]: + return { + "window_size": rng.randint(0, 65535), + "magic_value": rng.randint(0, 0xFFFFFFFF), + "position_value": rng.randint(-1_000_000_000, 1_000_000_000), + "ratio_value": round(rng.uniform(-500.0, 500.0), 3), + "precise_value": round(rng.uniform(-10_000.0, 10_000.0), 6), + } + + +def random_extremes(rng: random.Random) -> dict[str, object]: + return { + "trim_value": rng.randint(-128, 127), + "energy_value": rng.randint(-(2**50), 2**50), + "big_counter": rng.randint(0, 2**56), + } + + +def random_probe(rng: random.Random, seq: int) -> dict[str, object]: + return { + "probe_seq": seq, + "probe_toggle": bool(rng.getrandbits(1)), + "probe_window": rng.randint(0, 65535), + "probe_ratio": round(rng.uniform(-200.0, 200.0), 3), + "probe_mode": rng.randint(0, 3), + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Exercise ExamplePackets over TCP and UDP and verify packet/order parsing." + ) + parser.add_argument("--board-name", default="TEST") + parser.add_argument("--board-ip", default=None) + parser.add_argument("--tcp-port", type=int, default=None) + parser.add_argument("--udp-port", type=int, default=None) + parser.add_argument("--client-port", type=int, default=None) + parser.add_argument("--host-bind", default="0.0.0.0") + parser.add_argument("--iterations", type=int, default=25) + parser.add_argument("--timeout", type=float, default=8.0) + parser.add_argument("--seed", type=int, default=12345) + parser.add_argument("--chunk-max", type=int, default=7) + parser.add_argument("--chunk-gap-ms", type=int, default=4) + parser.add_argument("--connect-settle", type=float, default=0.5) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + schema = Schema(args.board_name) + board_ip = args.board_ip or schema.board_ip + tcp_port = args.tcp_port or schema.tcp_server_port + udp_port = args.udp_port or schema.udp_port + client_port = args.client_port or schema.tcp_client_port + rng = random.Random(args.seed) + + udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + udp_sock.bind((args.host_bind, udp_port)) + + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind((args.host_bind, client_port)) + listener.listen(1) + + client_conn: socket.socket | None = None + server_conn: socket.socket | None = None + + try: + print( + f"[INFO] Waiting for board TCP client on {args.host_bind}:{client_port} " + f"and UDP on {args.host_bind}:{udp_port}" + ) + client_conn = wait_for_accept(listener, args.timeout) + print("[INFO] Board-initiated Socket connected") + + server_conn = connect_with_retry(board_ip, tcp_port, args.timeout, local_bind=args.host_bind) + print(f"[INFO] Connected to board ServerSocket at {board_ip}:{tcp_port}") + + if args.connect_settle > 0: + time.sleep(args.connect_settle) + + _, heartbeat = recv_matching_packet( + udp_sock, + schema, + "heartbeat_snapshot", + lambda _: True, + args.timeout, + ) + expected_order_count = int(heartbeat["tcp_order_count"]) + expected_udp_count = int(heartbeat["udp_parse_count"]) + current_state = int(heartbeat["mirror_state"]) + print( + "[INFO] Baseline heartbeat " + f"orders={expected_order_count} udp={expected_udp_count} state={current_state}" + ) + + for iteration in range(args.iterations): + server_small = random_small_profile(rng) + client_small = random_small_profile(rng) + large_profile = random_large_profile(rng) + extremes = random_extremes(rng) + probe = random_probe(rng, expected_udp_count + iteration + 1) + + send_chunked( + server_conn, + schema.encode(schema.orders["set_small_profile"], server_small), + rng, + args.chunk_max, + args.chunk_gap_ms, + ) + expected_order_count += 1 + recv_matching_packet( + udp_sock, + schema, + "order_mirror", + lambda payload: values_match( + schema, + { + "tcp_order_count": expected_order_count, + "last_order_code": schema.orders["set_small_profile"]["id"], + "enable_flag": server_small["enable_flag"], + "small_counter": server_small["small_counter"], + "offset_value": server_small["offset_value"], + "mirror_mode": server_small["order_mode"], + }, + payload, + ), + args.timeout, + ) + + server_burst = ( + schema.encode(schema.orders["set_large_profile"], large_profile) + + schema.encode(schema.orders["set_extremes"], extremes) + ) + send_chunked( + server_conn, + server_burst, + rng, + args.chunk_max, + args.chunk_gap_ms, + ) + expected_order_count += 2 + recv_matching_packet( + udp_sock, + schema, + "numeric_mirror", + lambda payload: values_match(schema, large_profile, payload), + args.timeout, + ) + recv_matching_packet( + udp_sock, + schema, + "extremes_mirror", + lambda payload: values_match( + schema, + { + "trim_value": extremes["trim_value"], + "energy_value": extremes["energy_value"], + "big_counter": extremes["big_counter"], + "mirror_state": current_state, + }, + payload, + ), + args.timeout, + ) + + send_chunked( + client_conn, + schema.encode(schema.orders["set_small_profile"], client_small), + rng, + args.chunk_max, + args.chunk_gap_ms, + ) + expected_order_count += 1 + recv_matching_packet( + udp_sock, + schema, + "order_mirror", + lambda payload: values_match( + schema, + { + "tcp_order_count": expected_order_count, + "last_order_code": schema.orders["set_small_profile"]["id"], + "enable_flag": client_small["enable_flag"], + "small_counter": client_small["small_counter"], + "offset_value": client_small["offset_value"], + "mirror_mode": client_small["order_mode"], + }, + payload, + ), + args.timeout, + ) + + if iteration % 2 == 0: + next_state = rng.randint(0, 3) + send_chunked( + client_conn, + schema.encode( + schema.orders["set_state_code"], + {"order_state": next_state}, + ), + rng, + args.chunk_max, + args.chunk_gap_ms, + ) + current_state = next_state + else: + send_chunked( + client_conn, + schema.encode(schema.orders["bump_state"], {}), + rng, + args.chunk_max, + args.chunk_gap_ms, + ) + current_state = (current_state + 1) % 4 + expected_order_count += 1 + recv_matching_packet( + udp_sock, + schema, + "heartbeat_snapshot", + lambda payload: values_match( + schema, + { + "tcp_order_count": expected_order_count, + "mirror_state": current_state, + }, + payload, + ), + args.timeout, + ) + + udp_sock.sendto(schema.encode(schema.packets["udp_probe"], probe), (board_ip, udp_port)) + expected_udp_count += 1 + recv_matching_packet( + udp_sock, + schema, + "udp_probe_echo", + lambda payload: values_match( + schema, + { + "udp_parse_count": expected_udp_count, + "probe_seq": probe["probe_seq"], + "probe_toggle": probe["probe_toggle"], + "probe_window": probe["probe_window"], + "probe_ratio": probe["probe_ratio"], + "probe_mode": probe["probe_mode"], + }, + payload, + ), + args.timeout, + ) + + print( + f"[PASS] iteration={iteration + 1}/{args.iterations} " + f"orders={expected_order_count} udp={expected_udp_count}" + ) + + print( + "[PASS] ExamplePackets parsing verified " + f"across {args.iterations} iterations (TCP server, TCP client, UDP packet path)." + ) + return 0 + except Exception as exc: # noqa: BLE001 + print(f"[FAIL] {exc}", file=sys.stderr) + return 1 + finally: + if client_conn is not None: + client_conn.close() + if server_conn is not None: + server_conn.close() + listener.close() + udp_sock.close() + + +if __name__ == "__main__": + raise SystemExit(main()) From 12765df35ffc2d304b015ccb35c8bb913203c2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1ez?= Date: Sat, 28 Feb 2026 13:02:30 +0100 Subject: [PATCH 04/11] docs: add example guides and TCP/IP test manuals --- README.md | 1 + docs/examples/README.md | 115 +++++ docs/examples/example-adc.md | 51 +++ docs/examples/example-ethernet.md | 78 ++++ docs/examples/example-exti.md | 53 +++ docs/examples/example-hardfault.md | 75 +++ .../example-linear-sensor-characterization.md | 92 ++++ docs/examples/example-mpu.md | 100 ++++ docs/examples/example-packets.md | 144 ++++++ docs/examples/example-tcpip.md | 110 +++++ docs/template-project/build-debug.md | 6 + docs/template-project/example-tcpip.md | 191 ++++++++ docs/template-project/tcpip-change-manual.md | 431 ++++++++++++++++++ docs/template-project/testing.md | 12 + 14 files changed, 1459 insertions(+) create mode 100644 docs/examples/README.md create mode 100644 docs/examples/example-adc.md create mode 100644 docs/examples/example-ethernet.md create mode 100644 docs/examples/example-exti.md create mode 100644 docs/examples/example-hardfault.md create mode 100644 docs/examples/example-linear-sensor-characterization.md create mode 100644 docs/examples/example-mpu.md create mode 100644 docs/examples/example-packets.md create mode 100644 docs/examples/example-tcpip.md create mode 100644 docs/template-project/example-tcpip.md create mode 100644 docs/template-project/tcpip-change-manual.md diff --git a/README.md b/README.md index a5f9b940..187ed210 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ ctest --preset simulator-all - Template setup: [`docs/template-project/setup.md`](docs/template-project/setup.md) - Build and debug: [`docs/template-project/build-debug.md`](docs/template-project/build-debug.md) - Testing and quality: [`docs/template-project/testing.md`](docs/template-project/testing.md) +- TCP/IP hardware stress example: [`docs/template-project/example-tcpip.md`](docs/template-project/example-tcpip.md) - ST-LIB docs (inside this repository): [`deps/ST-LIB/docs/setup.md`](deps/ST-LIB/docs/setup.md) ## Main Working Modes diff --git a/docs/examples/README.md b/docs/examples/README.md new file mode 100644 index 00000000..bff5f46f --- /dev/null +++ b/docs/examples/README.md @@ -0,0 +1,115 @@ +# Examples + +This directory contains one focused document per example under `Core/Src/Examples/`. + +Use this folder as the quick reference for: + +- what each example is for +- how to build it +- what hardware it needs +- how to validate that it is working +- what a failure usually means + +## Common workflow + +List available examples: + +```sh +./tools/build-example.sh --list +``` + +Build one example on a Nucleo: + +```sh +./tools/build-example.sh --example adc --preset nucleo-debug --test 0 +``` + +Build one Ethernet example on a Nucleo: + +```sh +./tools/build-example.sh --example tcpip --preset nucleo-debug-eth --extra-cxx-flags "-DTCPIP_TEST_HOST_IP=192.168.1.9" +``` + +Flash the latest build: + +```sh +STM32_Programmer_CLI -c port=SWD mode=UR -w out/build/latest.elf -v -rst +``` + +## Documents + +- [ExampleADC](./example-adc.md) +- [ExampleEthernet](./example-ethernet.md) +- [ExampleEXTI](./example-exti.md) +- [ExampleHardFault](./example-hardfault.md) +- [ExampleMPU](./example-mpu.md) +- [ExamplePackets](./example-packets.md) +- [ExampleTCPIP](./example-tcpip.md) +- [LinearSensorCharacterization](./example-linear-sensor-characterization.md) + +## JSON_ADE / Packet Schema Compatibility + +Not all examples depend on the same generated packet schema. + +That matters because the generated `OrderPackets` and `DataPackets` APIs are produced from the active contents of: + +- `Core/Inc/Code_generation/JSON_ADE` +- `boards.json` +- the selected `BOARD_NAME` + +In practice, that means some examples are source-compatible only with a specific `JSON_ADE` branch or schema set. + +### Schema-independent examples + +These do not depend on generated `OrderPackets` / `DataPackets` symbols: + +- `ExampleADC` +- `ExampleEthernet` +- `ExampleEXTI` +- `ExampleHardFault` +- `ExampleMPU` +- `ExampleTCPIP` + +You can build them as long as the normal board/preset requirements are satisfied. + +### Examples tied to the current `TEST` schema + +- `ExamplePackets` + +`ExamplePackets` is designed around the current `TEST` packet schema active in this branch. + +It expects the generated API associated with the `TEST` board definition and the packet/order set used by `tools/example_packets_check.py`. + +The required `JSON_ADE` branch for this setup is: + +- `fw-test-packets` + +### Examples tied to a different legacy characterization schema + +- `LinearSensorCharacterization` + +`LinearSensorCharacterization` does not match the current `TEST` schema. + +It expects a different `JSON_ADE` branch or board definition that generates the legacy characterization symbols (`characterize`, `characterization`, `Value`, etc.). + +The required `JSON_ADE` branch for this setup is: + +- `linear-sensor-char` + +### Important consequence + +`ExamplePackets` and `LinearSensorCharacterization` are currently not plug-and-play compatible with the same `JSON_ADE` checkout. + +If you switch the `JSON_ADE` branch/schema to satisfy one of them, you can break the other. + +Before building a packet-dependent example, check its document first and confirm: + +- which `JSON_ADE` schema it expects +- which `BOARD_NAME` it must be generated with +- whether the required generated symbols exist + +## Notes + +- `tools/build-example.sh` now handles named tests such as `usage_fault` as `TEST_USAGE_FAULT`. +- If an example does not define any `TEST_*` selector, the script no longer injects `TEST_0` by default. +- `out/build/latest.elf` always points to the most recent MCU build, so flash immediately after building the example you want. diff --git a/docs/examples/example-adc.md b/docs/examples/example-adc.md new file mode 100644 index 00000000..bbeb37c8 --- /dev/null +++ b/docs/examples/example-adc.md @@ -0,0 +1,51 @@ +# ExampleADC + +## Purpose + +`ExampleADC` is the minimal ADC smoke test. + +It validates: + +- ADC peripheral initialization +- pin configuration on `PA0` +- repeated sampling through the `ADCDomain` API +- end-to-end visibility of the converted value in firmware memory + +## Build + +```sh +./tools/build-example.sh --example adc --preset nucleo-debug --test 0 +``` + +Equivalent macro selection: + +- `EXAMPLE_ADC` +- `TEST_0` + +## Hardware setup + +- Use a Nucleo or board build that exposes `PA0`. +- Wire `PA0` first to `3.3V`, then to `GND`. +- Open a debugger watch on `adc_value`. + +## Runtime behavior + +The example: + +- creates one ADC instance on `PA0` +- samples every 100 ms +- stores the latest raw reading in the global `adc_value` + +There is no UART, TCP, UDP, or automated host-side checker for this example. + +## Expected result + +- With `PA0` tied to `3.3V`, `adc_value` should move close to the top of the ADC range. +- With `PA0` tied to `GND`, `adc_value` should move close to zero. +- The variable should update continuously in the debugger. + +## What a failure usually means + +- Value does not move: wrong pin, missing board init, bad wiring, or ADC clock/config issue. +- Value is noisy or saturated: floating input, analog ground issue, or wrong source impedance. +- Build fails only for this example: board pin mapping does not support `PA0` as configured. diff --git a/docs/examples/example-ethernet.md b/docs/examples/example-ethernet.md new file mode 100644 index 00000000..efab601c --- /dev/null +++ b/docs/examples/example-ethernet.md @@ -0,0 +1,78 @@ +# ExampleEthernet + +## Purpose + +`ExampleEthernet` is the minimal Ethernet bring-up loop. + +It validates: + +- board Ethernet pinset selection +- PHY selection (`LAN8742`, `LAN8700`, or `KSZ8041`) +- MAC + IPv4 initialization +- continuous `eth_instance.update()` polling in the main loop +- basic board liveness through the status LED + +## Build + +Nucleo + on-board LAN8742: + +```sh +./tools/build-example.sh --example ethernet --preset nucleo-debug-eth --test 0 +``` + +Custom board with KSZ8041: + +```sh +./tools/build-example.sh --example ethernet --preset board-debug-eth-ksz8041 --test 0 +``` + +Equivalent macro selection: + +- `EXAMPLE_ETHERNET` +- `TEST_0` + +## Network configuration + +This example uses a fixed board IP: + +- Board IP: `192.168.1.7` +- Netmask: `255.255.0.0` +- MAC: `00:80:e1:00:01:07` + +## Runtime behavior + +- The LED on `PB0` is turned on and stays on. +- If Ethernet support is enabled (`STLIB_ETH`), the example continuously calls `eth_instance.update()`. +- There is no socket traffic in this example. + +## How to validate + +1. Connect the board Ethernet port to the host. +2. Put the host NIC on the same subnet. +3. Flash and reset the board. +4. Ping the board: + +```sh +ping 192.168.1.7 +``` + +## Expected result + +- The board responds to ping. +- The LED remains on. +- There should be no hard fault on boot. + +## What it does not validate + +- TCP socket creation +- UDP socket creation +- packet parsing +- reconnection logic + +Use `ExampleTCPIP` or `ExamplePackets` for that. + +## What a failure usually means + +- No ping and no link LEDs: PHY, cable, host NIC config, or wrong preset. +- LED on but no ping: MAC/PHY init issue, wrong subnet, or host routing problem. +- Build fails: wrong Ethernet preset for the board/PHY combination. diff --git a/docs/examples/example-exti.md b/docs/examples/example-exti.md new file mode 100644 index 00000000..91920669 --- /dev/null +++ b/docs/examples/example-exti.md @@ -0,0 +1,53 @@ +# ExampleEXTI + +## Purpose + +`ExampleEXTI` is the minimal external interrupt test. + +It validates: + +- EXTI configuration on `PC13` +- callback dispatch on both edges +- interaction between EXTI and a GPIO output (`PB0`) + +## Build + +```sh +./tools/build-example.sh --example exti --preset nucleo-debug --test 0 +``` + +Equivalent macro selection: + +- `EXAMPLE_EXTI` +- `TEST_0` + +## Hardware setup + +- Use the Nucleo user button on `PC13`. +- Observe the LED connected to `PB0`. + +## Runtime behavior + +- The firmware registers an EXTI callback on `PC13`. +- The trigger mode is `BOTH_EDGES`. +- Every edge calls `toggle_led()`. + +That means: + +- button press toggles the LED +- button release toggles the LED again + +## Expected result + +- The LED changes state on every transition of the button signal. +- The main loop stays idle; the observable behavior comes entirely from the interrupt. + +## What a failure usually means + +- Nothing happens: EXTI line not configured, wrong pin mapping, or callback not firing. +- Only one transition toggles: edge trigger configuration is wrong. +- Repeated chatter: mechanical bounce or noisy input. + +## Notes + +This example is intentionally simple. It is useful to verify interrupt routing before adding more complex interrupt-driven logic. diff --git a/docs/examples/example-hardfault.md b/docs/examples/example-hardfault.md new file mode 100644 index 00000000..bc98cf83 --- /dev/null +++ b/docs/examples/example-hardfault.md @@ -0,0 +1,75 @@ +# ExampleHardFault + +## Purpose + +`ExampleHardFault` is a controlled fault generator. + +It exists to validate: + +- hard fault capture in flash +- CFSR/MMFAR/BFAR decoding +- call stack extraction +- the offline analyzer in `hard_faullt_analysis.py` + +## Available tests + +- `memory_fault`: invalid memory access through an oversized buffer index +- `bus_fault`: write to `0xdead0000` +- `usage_fault`: explicit trap (`__builtin_trap()`) + +## Build + +Usage fault example: + +```sh +./tools/build-example.sh --example hardfault --preset nucleo-debug --test usage_fault +``` + +Bus fault example: + +```sh +./tools/build-example.sh --example hardfault --preset nucleo-debug --test bus_fault +``` + +Memory fault example: + +```sh +./tools/build-example.sh --example hardfault --preset nucleo-debug --test memory_fault +``` + +Equivalent macro selection: + +- `EXAMPLE_HARDFAULT` +- one of `TEST_USAGE_FAULT`, `TEST_BUS_FAULT`, `TEST_MEMORY_FAULT` + +## Runtime behavior + +Each test deliberately faults almost immediately after boot. + +This is expected. + +The goal is not for the application to keep running. The goal is to verify that the hard-fault logging path records useful diagnostic data. + +## How to validate + +1. Build and flash one fault mode. +2. Let the board execute and fault. +3. Run the analyzer: + +```sh +python3 hard_faullt_analysis.py +``` + +If the script says you must stop debugging first, disconnect the live debugger and run it again. + +## Expected result + +- The analyzer should report the corresponding fault category. +- It should print decoded CFSR bits. +- It should usually show a useful PC/call trace. + +## What a failure usually means + +- Analyzer says there was no hard fault: the example did not actually execute, or the log area was not updated. +- Analyzer cannot read flash: ST-LINK/SWD is not available or the board is still under active debug control. +- Fault type does not match the injected test: fault logging/decoding is wrong, or the test hit a different exception first. diff --git a/docs/examples/example-linear-sensor-characterization.md b/docs/examples/example-linear-sensor-characterization.md new file mode 100644 index 00000000..bb1693c9 --- /dev/null +++ b/docs/examples/example-linear-sensor-characterization.md @@ -0,0 +1,92 @@ +# LinearSensorCharacterization + +## Purpose + +`LinearSensorCharacterization` is an application example that performs online linear calibration of one ADC reading against a reference value received through generated packets. + +It is intended to validate: + +- ADC acquisition on `PA0` +- Ethernet runtime loop +- generated `OrderPackets` reception +- generated `DataPackets` transmission +- incremental linear regression (`slope` and `offset` estimation) + +## JSON_ADE dependency + +This example depends on a different packet schema than `ExamplePackets`. + +It is not compatible with the current `TEST` packet schema active in this branch. + +The required `JSON_ADE` branch for this example is: + +- `linear-sensor-char` + +It expects a legacy characterization-oriented `JSON_ADE` branch or board definition that generates the specific packet/order API used by this source. + +That means: + +- simply enabling `EXAMPLE_LINEAR_SENSOR_CHARACTERIZATION` is not enough +- the active `Core/Inc/Code_generation/JSON_ADE` contents must match this example +- the selected `BOARD_NAME` must generate the legacy characterization symbols + +At the moment, `ExamplePackets` and `LinearSensorCharacterization` are effectively tied to different `JSON_ADE` worlds. + +Switching the `JSON_ADE` branch/schema to make this example build can break `ExamplePackets`, and vice versa. + +## Current status in this branch + +This example is currently not directly buildable with the active `BOARD_NAME=TEST` packet schema. + +Reason: + +- the source expects generated symbols such as `OrderPackets::characterize_init` +- the current `TEST` JSON schema does not generate those symbols +- building this example with `BOARD_NAME=TEST` fails at compile time + +The failure is expected until a matching board schema is restored or the example is migrated to the new `TEST` packet definitions. + +## What it needs to build + +This example requires a board JSON definition that generates at least: + +- `OrderPackets::characterize_init` +- `OrderPackets::characterize_flag` +- `DataPackets::characterization_init` +- `DataPackets::Value_init` +- `DataPackets::packets_socket` +- `DataPackets::characterization_packet` + +In practice, this means you need the legacy characterization schema checked out in `JSON_ADE`, then a full reconfigure/rebuild so the generated packet headers are replaced. + +Typical sequence once that schema is restored: + +```sh +cmake --preset nucleo-debug-eth -DBUILD_EXAMPLES=ON -DCMAKE_CXX_FLAGS='-DEXAMPLE_LINEAR_SENSOR_CHARACTERIZATION' +cmake --build --preset nucleo-debug-eth +``` + +## Intended runtime behavior + +When backed by a matching packet schema, the example should: + +- read the raw ADC value from `PA0` +- receive a reference `real_value` through a generated order +- update `slope` and `offset` with incremental OLS +- compute `value = slope * sensor_value + offset` +- publish the updated characterization packet over Ethernet + +## Recommended next step + +Pick one of these approaches before using this example again: + +- restore the original board JSON that defined `characterize`, `characterization`, and `Value` +- or migrate `LinearSensorCharacterization.cpp` to the new `TEST` packet names + +## Validation once restored + +After the packet schema matches again, validate it by: + +- sending known reference values +- checking that `slope` and `offset` converge correctly +- verifying the emitted data packets track the fitted line diff --git a/docs/examples/example-mpu.md b/docs/examples/example-mpu.md new file mode 100644 index 00000000..05d6a28e --- /dev/null +++ b/docs/examples/example-mpu.md @@ -0,0 +1,100 @@ +# ExampleMPU + +## Purpose + +`ExampleMPU` is the MPU and static buffer allocation validation suite. + +It validates: + +- buffer reservation in different memory domains +- cached vs non-cached placement +- alignment and packing constraints +- type safety of `MPUDomain::Buffer` +- selected runtime fault scenarios for invalid access +- compatibility with legacy `MPUManager` allocation + +## Build + +Baseline build: + +```sh +./tools/build-example.sh --example mpu --preset nucleo-debug --test 0 +``` + +Pick any other selector with `--test `. + +Examples: + +```sh +./tools/build-example.sh --example mpu --preset nucleo-debug --test 11 +./tools/build-example.sh --example mpu --preset nucleo-debug --test 12 +``` + +Equivalent macro selection: + +- `EXAMPLE_MPU` +- one of `TEST_0` to `TEST_15` + +## Test matrix + +- `TEST_0`: no buffers requested; baseline boot path. +- `TEST_1`: one non-cached buffer in default domain (D2). +- `TEST_2`: one non-cached buffer in D1. +- `TEST_3`: one non-cached buffer in D3. +- `TEST_4`: intentionally oversized allocation; should fail. +- `TEST_5`: intentionally wrong `as<>` type usage; should fail. +- `TEST_6`: mixes cached and non-cached buffers in the same program. +- `TEST_7`: alignment stress with differently sized element types. +- `TEST_8`: non-POD type request (`std::vector`); should fail. +- `TEST_9`: too many buffers requested; should fail. +- `TEST_10`: POD struct buffer. +- `TEST_11`: mixed sizes, alignments, memory types, and legacy placement macros. +- `TEST_12`: invalid memory dereference at `0x80000000`; runtime fault expected. +- `TEST_13`: `construct<>()` path for in-place object construction. +- `TEST_14`: legacy `MPUManager::allocate_non_cached_memory()` compatibility. +- `TEST_15`: null pointer dereference; runtime fault expected. + +## Expected behavior by class of test + +Build-and-run tests: + +- `0`, `1`, `2`, `3`, `6`, `7`, `10`, `11`, `13`, `14` +- These should build, boot, and stay alive in the idle loop. + +Intentional build-time rejection tests: + +- `4`, `5`, `8`, `9` +- These are expected to fail to compile or fail during static/resource validation. + +Intentional runtime fault tests: + +- `12`, `15` +- These should build, then hard fault at runtime. + +## How to validate + +For successful runtime tests: + +- Confirm the board boots and stays running. +- Confirm there is no hard fault. + +For expected-failure tests: + +- Treat build failure as the pass condition. +- The point is to prove the API rejects invalid usage. + +For runtime fault tests: + +- Flash the build. +- Let it fault. +- Run: + +```sh +python3 hard_faullt_analysis.py +``` + +## What a failure usually means + +- A “should fail” test builds cleanly: a safety check is missing. +- A “should run” test faults: placement or MPU configuration is wrong. +- A runtime fault test does not fault: the invalid access path did not execute as expected. diff --git a/docs/examples/example-packets.md b/docs/examples/example-packets.md new file mode 100644 index 00000000..715e9db3 --- /dev/null +++ b/docs/examples/example-packets.md @@ -0,0 +1,144 @@ +# ExamplePackets + +## Purpose + +`ExamplePackets` is the packet and order parsing validation example built on top of generated `OrderPackets` and `DataPackets`. + +It validates: + +- TCP order parsing through the generated `ServerSocket` +- UDP packet parsing through the generated `DatagramSocket` +- board-to-host TCP client traffic through `Socket` +- generated enum and scalar serialization for mixed data types +- parser robustness under fragmented TCP writes +- autogenerated socket instantiation from **ADJ** +- autogenerated periodic packet scheduling through `Scheduler` + +## ADJ dependency + +This example is tied to the current packet schema used in this branch. + +It is not schema-agnostic. + +The required **ADJ** branch for this example is: + +- `fw-test-packets` + +It expects the active **ADJ** checkout to generate the `TEST` board packet API used by this example and by `tools/example_packets_check.py`. + +Concretely: + +- `BOARD_NAME` must resolve to the current `TEST` board definition +- the generated `OrderPackets` / `DataPackets` set must include the packet/order names used by this source +- if you switch **ADJ** to a different branch for another application, this example may stop compiling or the checker may stop matching + +This is the branch/schema that currently matches: + +- current `Core/Inc/Code_generation/JSON_ADE` contents in this repo +- current `boards.json` selection that exposes only `TEST` + +If that **ADJ** content changes, regenerate and verify that these generated symbols still exist before using the example: + +- `OrderPackets::set_small_profile_*` +- `OrderPackets::set_large_profile_*` +- `OrderPackets::set_extremes_*` +- `OrderPackets::bump_state_*` +- `OrderPackets::set_state_code_*` +- `DataPackets::order_mirror_*` +- `DataPackets::numeric_mirror_*` +- `DataPackets::extremes_mirror_*` +- `DataPackets::udp_probe_*` +- `DataPackets::heartbeat_snapshot_*` +- `DataPackets::telemetry_udp` +- `OrderPackets::control_test_tcp` +- `OrderPackets::control_test_client` + +## Build + +Nucleo Ethernet build: + +```sh +./tools/build-example.sh --example packets --preset nucleo-debug-eth +``` + +Equivalent macro selection: + +- `EXAMPLE_PACKETS` +- `BOARD_NAME=TEST` + +## Network configuration + +Fixed board configuration: + +- Board IP: `192.168.1.7` +- UDP `DatagramSocket` (`telemetry_udp`): `41001` +- TCP `ServerSocket` (`control_test_tcp`): `41000` +- TCP `Socket` (`control_test_client`) local/remote: `41002` / `41003` +- Host link IP expected by generated sockets: `192.168.1.9` + +## Runtime behavior + +The example: + +- starts generated `OrderPackets` and `DataPackets` +- accepts TCP orders on the generated control server +- accepts TCP orders on the board-initiated generated client socket once connected +- receives and sends UDP packets through the generated `DataPackets::telemetry_udp` +- relies on **ADJ** packet metadata so `DataPackets::start()` registers periodic send tasks in `Scheduler` +- publishes `order_mirror`, `numeric_mirror`, `extremes_mirror`, and `udp_probe_echo` periodically at `50 ms` +- publishes `heartbeat_snapshot` periodically at `250 ms` +- recreates the generated control `ServerSocket` in the example if the test client disconnects + +Orders exercised by the checker: + +- `set_small_profile` +- `set_large_profile` +- `set_extremes` +- `bump_state` +- `set_state_code` + +Data packets emitted by the firmware: + +- `order_mirror` +- `numeric_mirror` +- `extremes_mirror` +- `udp_probe_echo` +- `heartbeat_snapshot` + +## How to validate + +1. Put the host-side Nucleo link on `192.168.1.9` (for example with `tools/configure_nucleo_host_network_macos.sh`). +2. Build and flash the example. +3. Run the checker: + +```sh +./tools/example_packets_check.py --board-ip 192.168.1.7 --host-bind 192.168.1.9 --iterations 25 +``` + +When the host also has Wi‑Fi on `192.168.1.x`, binding to `192.168.1.9` is important. It forces the host-side TCP and UDP traffic to use the Nucleo link while leaving the Wi‑Fi route untouched. + +Useful stress knobs: + +- `--chunk-max`: maximum TCP fragment size used by the checker +- `--chunk-gap-ms`: delay between fragments +- `--connect-settle`: delay after connect before sending + +## What the checker validates + +- TCP fragmented order parsing still reconstructs full orders correctly. +- UDP probe packets are parsed into the right generated fields. +- The scheduler-driven UDP `DatagramSocket` sends the expected generated `DataPackets` on the configured periods. +- The board TCP client path reaches the host listener and accepts orders over the board-initiated `Socket`. + +## Expected result + +- All iterations pass. +- No hard fault. +- No corrupted fields in integer, floating-point, or enum values. + +## What a failure usually means + +- TCP mismatch only: stream reassembly or order parsing issue. +- UDP mismatch only: packet layout, endianness, or field offset issue. +- Client-path mismatch only: board `Socket` reconnect/send path issue. +- Hard fault: usually packet access, alignment, or invalid pointer handling in serialization. diff --git a/docs/examples/example-tcpip.md b/docs/examples/example-tcpip.md new file mode 100644 index 00000000..74574bf8 --- /dev/null +++ b/docs/examples/example-tcpip.md @@ -0,0 +1,110 @@ +# ExampleTCPIP + +## Purpose + +`ExampleTCPIP` is the full socket stress and robustness example. + +It validates: + +- `Server` / `ServerSocket` TCP server handling +- `Socket` TCP client handling +- `DatagramSocket` UDP handling +- request/response control traffic +- payload integrity under load +- forced disconnect and reconnect behavior +- burst streaming in both directions +- health telemetry collection during stress + +## Build + +Nucleo Ethernet build: + +```sh +./tools/build-example.sh --example tcpip --preset nucleo-debug-eth --extra-cxx-flags "-DTCPIP_TEST_HOST_IP=192.168.1.9" +``` + +Equivalent macro selection: + +- `EXAMPLE_TCPIP` +- optional compile-time overrides such as `TCPIP_TEST_HOST_IP`, `TCPIP_TEST_BOARD_IP`, and port macros + +Default network values inside the example: + +- Board IP: `192.168.1.7` +- TCP server port: `40000` +- TCP client local/remote: `40001` / `40002` +- UDP local/remote: `40003` / `40004` + +## Runtime behavior + +The example exposes: + +- a TCP command/control channel +- a TCP payload ingestion path with checksums +- a board-to-host TCP client stream +- a UDP probe/ack path +- runtime health pages retrievable by command + +Main command IDs: + +- `CMD_RESET` +- `CMD_PING` +- `CMD_GET_STATS` +- `CMD_FORCE_DISCONNECT` +- `CMD_BURST_SERVER` +- `CMD_BURST_CLIENT` +- `CMD_FORCE_CLIENT_RECONNECT` +- `CMD_GET_HEALTH` +- `CMD_RESET_HEALTH` + +## How to validate + +Base stress run: + +```sh +./tools/run_example_tcpip_stress.sh --board-ip 192.168.1.7 --host-bind 192.168.1.9 +``` + +Quality gate: + +```sh +./tools/example_tcpip_quality_gate.sh --board-ip 192.168.1.7 --host-bind 192.168.1.9 --base-runs 20 --aggr-runs 5 +``` + +Long soak: + +```sh +./tools/example_tcpip_soak.sh --board-ip 192.168.1.7 --host-bind 192.168.1.9 --duration-min 120 +``` + +Multi-hour soak: + +```sh +./tools/example_tcpip_soak_hours.sh --board-ip 192.168.1.7 --host-bind 192.168.1.9 --hours 8 --min-pass-ratio 0.90 +``` + +Use `--host-bind 192.168.1.9` when the USB/Ethernet link and the Wi‑Fi are both inside `192.168.1.x`. The stress tooling now binds the outbound control socket to that IP so the direct board link works without stealing the global route from Wi‑Fi. + +## What the scripts validate + +- control path ping/ack +- payload good/bad checksum accounting +- forced disconnect recovery +- TCP server burst throughput +- TCP client stream delivery to host +- UDP round-trip success ratio +- optional health-page inspection + +## Expected result + +- Base stress run ends with overall pass. +- Quality gate reaches the configured pass criteria. +- Soak pass ratio stays above the chosen threshold. +- No hard fault, silent packet corruption, or stuck reconnect state. + +## Deep references + +For the full command list, telemetry pages, and long-run scripts, see: + +- [Detailed ExampleTCPIP guide](../template-project/example-tcpip.md) +- [TCP/IP change manual](../template-project/tcpip-change-manual.md) diff --git a/docs/template-project/build-debug.md b/docs/template-project/build-debug.md index 8599bd85..9a8c146d 100644 --- a/docs/template-project/build-debug.md +++ b/docs/template-project/build-debug.md @@ -57,3 +57,9 @@ Useful tasks in `.vscode/tasks.json`: - `MCU | OpenOCD | Start Server` - `MCU | OpenOCD | RTT Console` - `MCU | ST-LINK | Start GDB Server` + +## 5. Example Guides + +Per-example build and validation guides live in: + +- [`docs/examples/README.md`](../examples/README.md) diff --git a/docs/template-project/example-tcpip.md b/docs/template-project/example-tcpip.md new file mode 100644 index 00000000..61d6ed1c --- /dev/null +++ b/docs/template-project/example-tcpip.md @@ -0,0 +1,191 @@ +# ExampleTCPIP + +`ExampleTCPIP` is a hardware stress example for: + +- `ServerSocket` (TCP server) +- `Socket` (TCP client from board to host) +- `DatagramSocket` (UDP) +- `Server` connection manager + +It includes command/control, payload integrity checks (checksum), burst/saturation traffic, forced disconnect/reconnect and UDP probe/ack. + +Deep implementation notes for all robustness changes: + +- [`docs/template-project/tcpip-change-manual.md`](tcpip-change-manual.md) + +Control command IDs used by the host script: + +- `CMD_RESET=1` +- `CMD_PING=2` +- `CMD_GET_STATS=3` +- `CMD_FORCE_DISCONNECT=4` +- `CMD_BURST_SERVER=5` +- `CMD_BURST_CLIENT=6` +- `CMD_FORCE_CLIENT_RECONNECT=7` +- `CMD_GET_HEALTH=8` (paged telemetry) +- `CMD_RESET_HEALTH=9` + +## 1. Build + +Build with Ethernet enabled and `EXAMPLE_TCPIP` defined. + +Example (board + KSZ8041): + +```sh +cmake --preset board-debug-eth-ksz8041 \ + -DBUILD_EXAMPLES=ON \ + -DCMAKE_CXX_FLAGS='-DEXAMPLE_TCPIP -DTCPIP_TEST_HOST_IP=192.168.1.9' +cmake --build --preset board-debug-eth-ksz8041 +``` + +Example (nucleo + LAN8742): + +```sh +cmake --preset nucleo-debug-eth \ + -DBUILD_EXAMPLES=ON \ + -DCMAKE_CXX_FLAGS='-DEXAMPLE_TCPIP -DTCPIP_TEST_HOST_IP=192.168.1.9' +cmake --build --preset nucleo-debug-eth +``` + +Notes: + +- `TCPIP_TEST_HOST_IP` must be the IPv4 of your laptop on the same Ethernet segment. +- Defaults (if not overridden in compile flags): + - `TCPIP_TEST_BOARD_IP="192.168.1.7"` + - TCP server port: `40000` + - TCP client local/remote ports: `40001` / `40002` + - UDP local/remote ports: `40003` / `40004` + +## 2. Flash and run + +Flash as usual (`out/build/latest.elf`) and power the board. + +One-shot automation (build + flash + ping + tests): + +```sh +./tools/run_example_tcpip_nucleo.sh \ + --iface en6 \ + --board-ip 192.168.1.7 \ + --base-runs 1 \ + --aggr-runs 0 +``` + +## 3. Run stress tests from laptop + +```sh +./tools/run_example_tcpip_stress.sh --board-ip 192.168.1.7 --host-bind 192.168.1.9 +``` + +Useful options: + +- `--host-bind 0.0.0.0` +- `--tcp-server-port 40000` +- `--tcp-client-port 40002` +- `--udp-local-port 40003` +- `--udp-remote-port 40004` +- `--good-payloads 1200` +- `--bad-payloads 200` +- `--min-payload-rx-ratio 0.90` +- `--min-bad-detect-ratio 0.80` +- `--payload-interval-us 800` (set `0` for max blast / likely RX overrun testing) +- `--server-burst 800` +- `--client-burst 800` +- `--min-server-burst-ratio 0.95` +- `--min-client-burst-ratio 0.95` +- `--udp-count 300` +- `--strict-client-stream` (make `tcp_client_stream` a hard fail instead of warning) +- `--health-pages 6` +- `--reset-health` +- `--health-at-end` +- `--no-health-on-fail` + +## 4. What the script validates + +- TCP command/response path (`PING`) +- TCP payload integrity under load (good + bad checksum packets) +- Forced disconnect and reconnect +- TCP server burst stream reception +- UDP probe/ack response ratio and board-reported counters +- Board TCP client stream reception on host-side sink + +## 5. Telemetry Pages (`CMD_GET_HEALTH`) + +`CMD_GET_HEALTH` returns three values per page: + +- `page 0`: `uptime_ms`, `loop_iterations`, `tcp_commands_rx` +- `page 1`: `tcp_payload_rx`, `tcp_payload_bad`, `tcp_responses_tx` +- `page 2`: `tcp_client_tx_ok`, `tcp_client_tx_fail`, `tcp_client_send_fail_streak_max` +- `page 3`: `tcp_server_recreate_count`, `tcp_client_recreate_count`, `tcp_client_reconnect_calls` +- `page 4`: `reason_last`, `reason_arg_last`, `reason_update_count` +- `page 5`: `server_burst_requested_max`, `client_burst_requested_max`, `tcp_client_not_connected_ticks` + +Current reason codes: + +- `0`: none/reset +- `1`: boot +- `2`: force-disconnect command +- `3`: server recreate +- `4`: client recreate by command +- `5`: client recreate watchdog +- `6`: client reconnect poll +- `7`: client send fail streak started + +## 6. Quality Gate + +Strict matrix (base + aggressive): + +```sh +./tools/example_tcpip_quality_gate.sh \ + --board-ip 192.168.1.7 \ + --host-bind 192.168.1.9 \ + --base-runs 20 \ + --aggr-runs 5 \ + --health-at-end +``` + +This script stores all logs in `out/quality-gate//`. +Useful flags: + +- `--health-pages 6` +- `--health-at-end` +- `--stop-on-first-fail` + +## 7. Soak Test + +Long-running soak with automatic pass/fail summary: + +```sh +./tools/example_tcpip_soak.sh \ + --board-ip 192.168.1.7 \ + --host-bind 192.168.1.9 \ + --duration-min 480 \ + --strict-client-stream \ + --health-pages 6 \ + --max-failures 1 +``` + +This script stores all logs in `out/soak//`. + +## 8. Multi-Hour / Overnight Soak + +Use the long-run wrapper to execute for hours and get final pass ratio + fail breakdown: + +```sh +./tools/example_tcpip_soak_hours.sh \ + --board-ip 192.168.1.7 \ + --host-bind 192.168.1.9 \ + --hours 8 \ + --min-pass-ratio 0.90 \ + --baseline-pass-ratio 0.8475 +``` + +Outputs: + +- Run console/session log: `out/soak-hours/.log` +- Per-run logs from soak engine: `out/soak//` + +To leave it running in background: + +```sh +nohup ./tools/example_tcpip_soak_hours.sh --board-ip 192.168.1.7 --host-bind 192.168.1.9 --hours 8 > out/soak-hours/latest.nohup.log 2>&1 & +``` diff --git a/docs/template-project/tcpip-change-manual.md b/docs/template-project/tcpip-change-manual.md new file mode 100644 index 00000000..571f6cbd --- /dev/null +++ b/docs/template-project/tcpip-change-manual.md @@ -0,0 +1,431 @@ +****# Manual de Cambios TCP/IP (ST-LIB + ExampleTCPIP) + +Este documento explica en detalle lo que se ha cambiado en: + +- `Server` +- `ServerSocket` +- `Socket` +- `DatagramSocket` +- `ExampleTCPIP` +- scripts de test/soak y entorno host + +Objetivo principal de los cambios: + +- reducir fallos intermitentes bajo carga +- hacer recuperación automática ante desconexiones +- eliminar rutas peligrosas de memoria/estado +- mejorar trazabilidad de fallos con telemetría + +## 1. Arquitectura de alto nivel + +### 1.1 Bloques principales + +- `Server` administra varias conexiones TCP entrantes (`ServerSocket`). +- `ServerSocket` representa una conexión TCP aceptada por el server. +- `Socket` representa un cliente TCP saliente (board -> host). +- `DatagramSocket` maneja UDP board <-> host. +- `ExampleTCPIP` orquesta todo y expone comandos de control para tests. + +### 1.2 Flujo operativo en ExampleTCPIP + +- Host envía comandos TCP (`CMD_PING`, `CMD_BURST_SERVER`, etc.). +- Firmware responde por `TCPIP_RESPONSE_ORDER_ID`. +- Se prueban: +- integridad de payload TCP +- ráfagas server->host +- ráfagas client(board)->host +- roundtrip UDP +- desconexión/reconexión forzada + +## 2. Cambios en `OrderProtocol` + +Archivo: `deps/ST-LIB/Inc/HALAL/Models/Packets/OrderProtocol.hpp` + +Cambio: + +- se añadió destructor virtual. + +Por qué: + +- `OrderProtocol` es clase base polimórfica. +- sin destructor virtual, un `delete` vía puntero base puede ser UB/fuga. + +## 3. Cambios en `Server` + +Archivos: + +- `deps/ST-LIB/Inc/ST-LIB_LOW/Communication/Server/Server.hpp` +- `deps/ST-LIB/Src/ST-LIB_LOW/Communication/Server/Server.cpp` + +### 3.1 Gestión de memoria y ciclo de vida + +Antes: + +- había llamadas explícitas a destructores (`obj->~ServerSocket()`), peligrosas. + +Ahora: + +- se usa `delete` real. +- se limpian punteros a `nullptr`. +- se elimina el server de `running_servers` de forma segura. + +### 3.2 Lógica de `update()` más robusta + +Ahora: + +- si `status == CLOSED`, no hace nada. +- si el listener (`open_connection`) no está conectado ni escuchando, se recrea. +- si llega una conexión y hay capacidad, se mueve a `running_connections`. +- si no hay capacidad, se cierra esa nueva conexión sin romper sesiones actuales. +- se compacta el array de conexiones activas y se borran desconectadas. + +Efecto: + +- evita estados zombis. +- evita crecimiento de conexiones inválidas. +- evita caer a fault por una desconexión normal. + +### 3.3 `broadcast_order` ahora devuelve `bool` + +Antes: + +- era `void`; no se podía saber si se envió a alguien. + +Ahora: + +- devuelve `true` si al menos una conexión aceptó el envío. + +Efecto: + +- `ExampleTCPIP` puede decidir si la respuesta realmente salió por el canal esperado. + +## 4. Cambios en `ServerSocket` + +Archivos: + +- `deps/ST-LIB/Inc/HALAL/Services/Communication/Ethernet/LWIP/TCP/ServerSocket.hpp` +- `deps/ST-LIB/Src/HALAL/Services/Communication/Ethernet/LWIP/TCP/ServerSocket.cpp` + +### 4.1 TX queue y envío + +Cambios: + +- `MAX_TX_QUEUE_DEPTH` subido a `64` (antes `24`). +- `send_order()` usa `add_order_to_queue()`. +- si cola llena momentáneamente, intenta un flush (`send()`) y reintenta una vez. + +Efecto: + +- menos falsos fallos bajo ráfagas. +- mejor aprovechamiento del buffer TCP. + +### 4.2 Parsing RX por stream + +Cambio clave: + +- se introdujo `rx_stream_buffer` y parser incremental (`process_order_stream`). + +Por qué: + +- TCP no preserva framing de mensajes. +- un `Order` puede llegar fragmentado o varios `Order` juntos. + +Comportamiento: + +- acumula bytes. +- busca `order_id`. +- valida tamaño. +- procesa solo frames completos. +- resincroniza si encuentra basura/ID desconocido. +- limita buffer a `8192` bytes para no crecer infinito. + +### 4.3 Callbacks lwIP reforzados + +Cambios: + +- comprobaciones nulas en callbacks. +- en errores transitorios de `receive_callback`, no mata conexión agresivamente. +- `error_callback` asume que lwIP ya liberó PCB y limpia estado local. +- `poll_callback` y `send_callback` drenan TX/RX y cierran limpio en `CLOSING`. + +### 4.4 Estados adicionales útiles + +Cambio: + +- nuevo `is_listening() const`. + +Uso: + +- `Server::update()` detecta listener inválido y lo recrea. + +## 5. Cambios en `Socket` (cliente TCP board->host) + +Archivos: + +- `deps/ST-LIB/Inc/HALAL/Services/Communication/Ethernet/LWIP/TCP/Socket.hpp` +- `deps/ST-LIB/Src/HALAL/Services/Communication/Ethernet/LWIP/TCP/Socket.cpp` + +### 5.1 Inicialización y punteros seguros + +Cambios: + +- `connection_control_block` y `socket_control_block` inicializados a `nullptr`. +- limpieza consistente en `close()`, destructor y `operator=`. + +### 5.2 TX queue y envío + +Cambios: + +- `MAX_TX_QUEUE_DEPTH` a `64`. +- `send_order()`: +- verifica `state == CONNECTED`. +- si no conectado, intenta `reconnect()`. +- enqueue con `add_order_to_queue()`. +- flush oportunista + reintento cuando cola momentáneamente llena. + +### 5.3 Parsing RX por stream (igual filosofía que ServerSocket) + +Cambio: + +- `rx_stream_buffer` + parser incremental para soportar fragmentación TCP. + +### 5.4 Reconexión y watchdog de handshake + +Cambios: + +- `pending_connection_reset` y `connect_poll_ticks`. +- `connection_poll_callback()` incrementa ticks cuando queda en `SYN_SENT`. +- si se estanca (`>=20` ticks), marca reset pendiente. +- `reconnect()` dispara `reset()` cuando hay reset pendiente o no hay PCB válido. + +Efecto: + +- menos sockets atascados en handshake. +- recuperación autónoma sin reiniciar firmware. + +### 5.5 Limpieza de código redundante/muerto + +Hecho: + +- se eliminaron rutas de abortado duplicadas tras `close()`. +- se simplificó `connection_error_callback` para limpiar estado sin ramas inútiles. + +## 6. Cambios en `DatagramSocket` (UDP) + +Archivos: + +- `deps/ST-LIB/Inc/HALAL/Services/Communication/Ethernet/LWIP/UDP/DatagramSocket.hpp` +- `deps/ST-LIB/Src/HALAL/Services/Communication/Ethernet/LWIP/UDP/DatagramSocket.cpp` + +### 6.1 Robustez de ciclo de vida + +Cambios: + +- `udp_control_block` inicializado a `nullptr`. +- checks de `udp_new()` y `udp_bind()` con rollback completo. +- `close()` y `reconnect()` idempotentes (si no hay PCB, no rompen). +- move ctor/assignment corregidos para transferencia de ownership real. + +### 6.2 RX seguro con pbuf chain + +Antes: + +- se parseaba directo `packet_buffer->payload` (peligroso si `pbuf` encadenado). + +Ahora: + +- copia con `pbuf_copy_partial()` a buffer continuo. +- parsea solo si tamaño minimo valido. + +Efecto: + +- evita leer memoria incompleta/corrupta en UDP segmentado. + +## 7. Cambios en lwIP config relevantes + +Archivo: `deps/ST-LIB/LWIP/Target/lwipopts.h` + +Cambio: + +- se añadieron macros explícitas: +- `CHECKSUM_GEN_ICMP 0` +- `CHECKSUM_CHECK_ICMP 0` + +Interpretación: + +- coherente con `CHECKSUM_BY_HARDWARE=1` y resto de checksums en `0` (offload HW). + +## 8. Cambios en `ExampleTCPIP` (firmware de pruebas) + +Archivo: `Core/Src/Examples/ExampleTCPIP.cpp` + +### 8.1 Respuesta de control fiable + +Cambio clave: + +- `try_send_tcp_response()` distingue envío por canal cliente vs canal server. +- si hay conexiones server activas, considera éxito solo si respondió por server. + +Por qué: + +- evita “ACK enviado” por el socket cliente cuando el control real iba por server. +- corrige timeouts falsos de comandos en el script. + +### 8.2 Cola de respuestas pendientes + +Cambios: + +- `queue_tcp_response()` + `flush_pending_tcp_response()`. +- no se pierde respuesta si en ese instante no hay ventana TCP disponible. + +### 8.3 Burst server/client con decremento correcto + +Cambio: + +- `server_burst_remaining` y `client_burst_remaining` decrementan solo en envío OK. + +Efecto: + +- evita contar como enviados paquetes que realmente no salieron. + +### 8.4 Backoff/pacing y heartbeats + +Cambios: + +- pacing con `next_*_burst_attempt_ms`. +- heartbeats del cliente solo cuando no hay burst activo. +- reduce interferencia entre tráfico de control y tráfico de estrés. + +### 8.5 Reconexión del `Socket` cliente más fina + +Cambios: + +- `CLIENT_RECONNECT_MS` a `500ms`. +- recreación pesada por contador (`CLIENT_RECONNECT_RECREATE_EVERY=60`) en vez de agresiva. +- watchdog adicional por racha de fallos de envío. + +Efecto: + +- menor inestabilidad en `tcp_client_stream`. + +### 8.6 Health telemetry + +Se añadieron/estandarizaron páginas de health (`CMD_GET_HEALTH`) para diagnosticar: + +- loops, comandos, payload rx/bad +- tx ok/fail del cliente TCP +- conteo de recreaciones y reconexiones +- último motivo de evento +- máximos de burst y ticks desconectado + +## 9. Cambios en `example_tcpip_stress.py` + +Archivo: `tools/example_tcpip_stress.py` + +### 9.1 Matching fuerte de respuestas + +Cambio: + +- `wait_response_matching(...)` permite validar `value0/1/2` esperados. + +Efecto: + +- evita confundir respuestas viejas con respuestas del comando actual. + +### 9.2 Integridad payload más estable + +Cambio: + +- tras enviar payloads, hace polling de stats con ventana de asentamiento. + +Efecto: + +- reduce falsos negativos por latencia de actualización de contadores en firmware. + +### 9.3 Ventanas dinámicas en burst/client-stream + +Cambio: + +- tiempo de colección ajustado al tamaño de burst. + +Efecto: + +- menos sensibilidad a jitter temporal. + +### 9.4 Check client-stream más robusto + +Cambios: + +- nudge de conexión cuando sink está inactivo. +- reintentos controlados. +- validación por ventana agregada (`aggregate_window_pass`) para tolerar ruido de microventanas. + +## 10. Cambios en scripts de ejecución + +### 10.1 Host network seguro en macOS + +Archivo: `tools/configure_nucleo_host_network_macos.sh` + +Qué añade: + +- prioriza Wi-Fi y mantiene USB-Ethernet para la placa. +- valida ruta default (internet) y ruta a board por interfaz correcta. +- detecta bloqueo de permiso "Local Network" y lo reporta claramente. + +### 10.2 Runner end-to-end de Nucleo + +Archivo: `tools/run_example_tcpip_nucleo.sh` + +Qué añade: + +- preflight de rutas y diagnóstico local-network. +- build con `TCPIP_TEST_HOST_IP` y `TCPIP_TEST_BOARD_IP`. +- flash con `stm32prog` o `openocd`. +- fallback de OpenOCD si `verify` falla por mismatch típico de secciones RAM. + +### 10.3 Quality gate y soak + +Archivos: + +- `tools/example_tcpip_quality_gate.sh` +- `tools/example_tcpip_soak.sh` +- `tools/example_tcpip_soak_hours.sh` + +Qué añade: + +- matriz base/agresiva repetible con logs por run. +- preflight ping entre runs. +- soak largo con resumen de ratio PASS/FAIL y breakdown por test fallido. + +## 11. Qué problemas se buscaba solucionar + +Síntomas observados antes: + +- fallos intermitentes en `tcp_client_stream`. +- timeouts puntuales en respuestas de comando (`tcp_server_burst`). +- reconexiones no deterministas. +- posibles riesgos de lifecycle/ownership en sockets. + +Estado tras cambios: + +- mejora clara de estabilidad. +- siguen existiendo fallos aislados bajo soak estricto (baja frecuencia). + +## 12. Riesgos residuales y próximos pasos recomendados + +Pendiente para casi "bulletproof": + +- incluir token de correlación explícito request/response a nivel protocolo de control. +- exponer métricas internas de cola TX y errores lwIP (`ERR_MEM`, `ERR_RST`, etc.) por health page. +- ejecutar soak nocturno y usar baseline de ratio para detectar regresiones automáticamente. + +## 13. Guía rápida de lectura del código + +Orden recomendado para entenderlo sin perderse: + +1. `ExampleTCPIP.cpp` (qué se prueba y cómo se orquesta). +2. `Server.cpp` + `ServerSocket.cpp` (camino de control server-side). +3. `Socket.cpp` (camino board->host y reconexión). +4. `DatagramSocket.cpp` (UDP). +5. `example_tcpip_stress.py` (cómo se verifica desde host). +6. `example_tcpip_quality_gate.sh` / `example_tcpip_soak*.sh` (cómo se automatiza a largo plazo). diff --git a/docs/template-project/testing.md b/docs/template-project/testing.md index 51c7522f..297d3ea3 100644 --- a/docs/template-project/testing.md +++ b/docs/template-project/testing.md @@ -44,3 +44,15 @@ pre-commit run --all-files - `Compile Checks`: builds MCU matrix (no simulator tests) - `Run Simulator Tests`: runs tests using `simulator` preset - `Format Checks`: validates formatting with `pre-commit` + +## 5. TCP/IP Hardware Stress Tests + +For Ethernet/socket stress testing on real hardware, see: + +- [`docs/template-project/example-tcpip.md`](example-tcpip.md) +- Deep-dive implementation manual: [`docs/template-project/tcpip-change-manual.md`](tcpip-change-manual.md) +- Per-example quick guides: [`docs/examples/README.md`](../examples/README.md) +- One-shot Nucleo flow: `./tools/run_example_tcpip_nucleo.sh` +- Run strict matrix gate: `./tools/example_tcpip_quality_gate.sh` +- Run long soak: `./tools/example_tcpip_soak.sh` +- Run multi-hour soak + ratio summary: `./tools/example_tcpip_soak_hours.sh` From 6825dac68d51aa11afa2cba7a1954ccf397c0992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1ez?= Date: Sat, 28 Feb 2026 13:29:12 +0100 Subject: [PATCH 05/11] chore(packets): untrack generated packet headers and document schema usage --- .gitignore | 4 + Core/Inc/Code_generation/JSON_ADE | 2 +- .../Communications/Packets/DataPackets.hpp | 177 ------------------ .../Communications/Packets/OrderPackets.hpp | 109 ----------- README.md | 3 + docs/examples/README.md | 3 + docs/examples/example-packets.md | 4 +- docs/template-project/build-debug.md | 7 + docs/template-project/testing.md | 9 + 9 files changed, 29 insertions(+), 289 deletions(-) delete mode 100644 Core/Inc/Communications/Packets/DataPackets.hpp delete mode 100644 Core/Inc/Communications/Packets/OrderPackets.hpp diff --git a/.gitignore b/.gitignore index a9117866..920d880c 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,7 @@ tools/*.bin # Log files *.log + +# Generated packet headers +/Core/Inc/Communications/Packets/DataPackets.hpp +/Core/Inc/Communications/Packets/OrderPackets.hpp diff --git a/Core/Inc/Code_generation/JSON_ADE b/Core/Inc/Code_generation/JSON_ADE index 5b856377..d0f7d80d 160000 --- a/Core/Inc/Code_generation/JSON_ADE +++ b/Core/Inc/Code_generation/JSON_ADE @@ -1 +1 @@ -Subproject commit 5b856377fb0fb3ca67bc4467598cec986116fe22 +Subproject commit d0f7d80d726bc9e6d5f4e1d13f3a120d63fedec4 diff --git a/Core/Inc/Communications/Packets/DataPackets.hpp b/Core/Inc/Communications/Packets/DataPackets.hpp deleted file mode 100644 index e7fe74cd..00000000 --- a/Core/Inc/Communications/Packets/DataPackets.hpp +++ /dev/null @@ -1,177 +0,0 @@ -#pragma once -#include "ST-LIB.hpp" - -/*Data packets for TEST --AUTOGENERATED CODE, DO NOT MODIFY-*/ -class DataPackets { -public: - enum class mirror_mode : uint8_t { - IDLE = 0, - RUN = 1, - SAFE = 2, - CAL = 3, - }; - enum class mirror_state : uint8_t { - BOOT = 0, - ARMED = 1, - STREAMING = 2, - ERROR = 3, - }; - enum class probe_mode : uint8_t { - LOW = 0, - MEDIUM = 1, - HIGH = 2, - TURBO = 3, - }; - - static void order_mirror_init( - uint32_t& tcp_order_count, - uint16_t& last_order_code, - bool& enable_flag, - uint8_t& small_counter, - int16_t& offset_value, - mirror_mode& mirror_mode - ) { - order_mirror_packet = new HeapPacket( - static_cast(20737), - &tcp_order_count, - &last_order_code, - &enable_flag, - &small_counter, - &offset_value, - &mirror_mode - ); - } - - static void numeric_mirror_init( - uint16_t& window_size, - uint32_t& magic_value, - int32_t& position_value, - float& ratio_value, - double& precise_value - ) { - numeric_mirror_packet = new HeapPacket( - static_cast(20738), - &window_size, - &magic_value, - &position_value, - &ratio_value, - &precise_value - ); - } - - static void extremes_mirror_init( - int8_t& trim_value, - int64_t& energy_value, - uint64_t& big_counter, - mirror_state& mirror_state - ) { - extremes_mirror_packet = new HeapPacket( - static_cast(20739), - &trim_value, - &energy_value, - &big_counter, - &mirror_state - ); - } - - static void udp_probe_init( - uint32_t& probe_seq, - bool& probe_toggle, - uint16_t& probe_window, - float& probe_ratio, - probe_mode& probe_mode - ) { - udp_probe_packet = new HeapPacket( - static_cast(20740), - &probe_seq, - &probe_toggle, - &probe_window, - &probe_ratio, - &probe_mode - ); - } - - static void udp_probe_echo_init( - uint32_t& udp_parse_count, - uint32_t& probe_seq, - bool& probe_toggle, - uint16_t& probe_window, - float& probe_ratio, - probe_mode& probe_mode - ) { - udp_probe_echo_packet = new HeapPacket( - static_cast(20741), - &udp_parse_count, - &probe_seq, - &probe_toggle, - &probe_window, - &probe_ratio, - &probe_mode - ); - } - - static void heartbeat_snapshot_init( - uint32_t& heartbeat_ticks, - uint32_t& tcp_order_count, - uint32_t& udp_parse_count, - mirror_state& mirror_state - ) { - heartbeat_snapshot_packet = new HeapPacket( - static_cast(20742), - &heartbeat_ticks, - &tcp_order_count, - &udp_parse_count, - &mirror_state - ); - } - -public: - inline static HeapPacket* order_mirror_packet{nullptr}; - inline static HeapPacket* numeric_mirror_packet{nullptr}; - inline static HeapPacket* extremes_mirror_packet{nullptr}; - inline static HeapPacket* udp_probe_packet{nullptr}; - inline static HeapPacket* udp_probe_echo_packet{nullptr}; - inline static HeapPacket* heartbeat_snapshot_packet{nullptr}; - - inline static DatagramSocket* telemetry_udp{nullptr}; - - static void start() { - if (order_mirror_packet == nullptr) { - ErrorHandler("Packet order_mirror not initialized"); - } - if (numeric_mirror_packet == nullptr) { - ErrorHandler("Packet numeric_mirror not initialized"); - } - if (extremes_mirror_packet == nullptr) { - ErrorHandler("Packet extremes_mirror not initialized"); - } - if (udp_probe_packet == nullptr) { - ErrorHandler("Packet udp_probe not initialized"); - } - if (udp_probe_echo_packet == nullptr) { - ErrorHandler("Packet udp_probe_echo not initialized"); - } - if (heartbeat_snapshot_packet == nullptr) { - ErrorHandler("Packet heartbeat_snapshot not initialized"); - } - - telemetry_udp = new DatagramSocket("192.168.1.7", 41001, "192.168.1.9", 41001); - - Scheduler::register_task( - 50000, - +[]() { - DataPackets::telemetry_udp->send_packet(*DataPackets::order_mirror_packet); - DataPackets::telemetry_udp->send_packet(*DataPackets::numeric_mirror_packet); - DataPackets::telemetry_udp->send_packet(*DataPackets::extremes_mirror_packet); - DataPackets::telemetry_udp->send_packet(*DataPackets::udp_probe_echo_packet); - } - ); - Scheduler::register_task( - 250000, - +[]() { - DataPackets::telemetry_udp->send_packet(*DataPackets::heartbeat_snapshot_packet); - } - ); - } -}; diff --git a/Core/Inc/Communications/Packets/OrderPackets.hpp b/Core/Inc/Communications/Packets/OrderPackets.hpp deleted file mode 100644 index 1a0ac6cf..00000000 --- a/Core/Inc/Communications/Packets/OrderPackets.hpp +++ /dev/null @@ -1,109 +0,0 @@ -#pragma once -#include "ST-LIB.hpp" - -/*Order packets for TEST --AUTOGENERATED CODE, DO NOT MODIFY- */ - -class OrderPackets { -public: - enum class order_mode : uint8_t { - IDLE = 0, - RUN = 1, - SAFE = 2, - CAL = 3, - }; - enum class order_state : uint8_t { - BOOT = 0, - ARMED = 1, - STREAMING = 2, - ERROR = 3, - }; - - inline static bool set_small_profile_flag{false}; - inline static bool set_large_profile_flag{false}; - inline static bool set_extremes_flag{false}; - inline static bool bump_state_flag{false}; - inline static bool set_state_code_flag{false}; - - OrderPackets() = default; - - inline static HeapOrder* set_small_profile_order{nullptr}; - inline static HeapOrder* set_large_profile_order{nullptr}; - inline static HeapOrder* set_extremes_order{nullptr}; - inline static HeapOrder* bump_state_order{nullptr}; - inline static HeapOrder* set_state_code_order{nullptr}; - - static void set_small_profile_init( - bool& enable_flag, - uint8_t& small_counter, - int16_t& offset_value, - order_mode& order_mode - ) { - set_small_profile_order = new HeapOrder( - 20481, - &set_small_profile_cb, - &enable_flag, - &small_counter, - &offset_value, - &order_mode - ); - } - static void set_large_profile_init( - uint16_t& window_size, - uint32_t& magic_value, - int32_t& position_value, - float& ratio_value, - double& precise_value - ) { - set_large_profile_order = new HeapOrder( - 20482, - &set_large_profile_cb, - &window_size, - &magic_value, - &position_value, - &ratio_value, - &precise_value - ); - } - static void - set_extremes_init(int8_t& trim_value, int64_t& energy_value, uint64_t& big_counter) { - set_extremes_order = - new HeapOrder(20483, &set_extremes_cb, &trim_value, &energy_value, &big_counter); - } - static void bump_state_init() { bump_state_order = new HeapOrder(20484, &bump_state_cb); } - static void set_state_code_init(order_state& order_state) { - set_state_code_order = new HeapOrder(20485, &set_state_code_cb, &order_state); - } - - inline static Socket* control_test_client{nullptr}; - - inline static ServerSocket* control_test_tcp{nullptr}; - - static void start() { - if (set_small_profile_order == nullptr) { - ErrorHandler("Order set_small_profile not initialized"); - } - if (set_large_profile_order == nullptr) { - ErrorHandler("Order set_large_profile not initialized"); - } - if (set_extremes_order == nullptr) { - ErrorHandler("Order set_extremes not initialized"); - } - if (bump_state_order == nullptr) { - ErrorHandler("Order bump_state not initialized"); - } - if (set_state_code_order == nullptr) { - ErrorHandler("Order set_state_code not initialized"); - } - - control_test_tcp = new ServerSocket("192.168.1.7", 41000); - control_test_client = new Socket("192.168.1.7", 41002, "192.168.1.9", 41003); - } - -private: - static void set_small_profile_cb() { set_small_profile_flag = true; } - static void set_large_profile_cb() { set_large_profile_flag = true; } - static void set_extremes_cb() { set_extremes_flag = true; } - static void bump_state_cb() { bump_state_flag = true; } - static void set_state_code_cb() { set_state_code_flag = true; } -}; diff --git a/README.md b/README.md index 187ed210..0ab821c0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ ctest --preset simulator-all - Template setup: [`docs/template-project/setup.md`](docs/template-project/setup.md) - Build and debug: [`docs/template-project/build-debug.md`](docs/template-project/build-debug.md) - Testing and quality: [`docs/template-project/testing.md`](docs/template-project/testing.md) +- Per-example guides: [`docs/examples/README.md`](docs/examples/README.md) - TCP/IP hardware stress example: [`docs/template-project/example-tcpip.md`](docs/template-project/example-tcpip.md) - ST-LIB docs (inside this repository): [`deps/ST-LIB/docs/setup.md`](deps/ST-LIB/docs/setup.md) @@ -51,3 +52,5 @@ Example: ```sh cmake --preset board-debug -DBOARD_NAME=TEST ``` + +Generated packet headers such as `Core/Inc/Communications/Packets/DataPackets.hpp` and `Core/Inc/Communications/Packets/OrderPackets.hpp` are build outputs derived from the active `JSON_ADE` schema. They are intentionally gitignored and should not be edited or committed. diff --git a/docs/examples/README.md b/docs/examples/README.md index bff5f46f..bc83488f 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -30,6 +30,8 @@ Build one Ethernet example on a Nucleo: ./tools/build-example.sh --example tcpip --preset nucleo-debug-eth --extra-cxx-flags "-DTCPIP_TEST_HOST_IP=192.168.1.9" ``` +For Ethernet examples, configure the host-side board-link interface with a static IPv4 on the same subnet as the board (default examples expect `192.168.1.9` on the host and `192.168.1.7` on the board). + Flash the latest build: ```sh @@ -113,3 +115,4 @@ Before building a packet-dependent example, check its document first and confirm - `tools/build-example.sh` now handles named tests such as `usage_fault` as `TEST_USAGE_FAULT`. - If an example does not define any `TEST_*` selector, the script no longer injects `TEST_0` by default. - `out/build/latest.elf` always points to the most recent MCU build, so flash immediately after building the example you want. +- `Core/Inc/Communications/Packets/DataPackets.hpp` and `Core/Inc/Communications/Packets/OrderPackets.hpp` are generated packet headers. They are not source-of-truth files and are intentionally gitignored. diff --git a/docs/examples/example-packets.md b/docs/examples/example-packets.md index 715e9db3..1589c5a5 100644 --- a/docs/examples/example-packets.md +++ b/docs/examples/example-packets.md @@ -9,7 +9,7 @@ It validates: - TCP order parsing through the generated `ServerSocket` - UDP packet parsing through the generated `DatagramSocket` - board-to-host TCP client traffic through `Socket` -- generated enum and scalar serialization for mixed data types +- generated enum and scalar serialization for bool, signed integer, unsigned integer, floating-point, and enum fields - parser robustness under fragmented TCP writes - autogenerated socket instantiation from **ADJ** - autogenerated periodic packet scheduling through `Scheduler` @@ -107,7 +107,7 @@ Data packets emitted by the firmware: ## How to validate -1. Put the host-side Nucleo link on `192.168.1.9` (for example with `tools/configure_nucleo_host_network_macos.sh`). +1. Configure the host-side board-link interface with IPv4 `192.168.1.9`. 2. Build and flash the example. 3. Run the checker: diff --git a/docs/template-project/build-debug.md b/docs/template-project/build-debug.md index 9a8c146d..4bab67f3 100644 --- a/docs/template-project/build-debug.md +++ b/docs/template-project/build-debug.md @@ -40,6 +40,13 @@ The build output is copied to: - `out/build/latest.elf` +If the selected `BOARD_NAME` enables packet code generation, the build also regenerates: + +- `Core/Inc/Communications/Packets/DataPackets.hpp` +- `Core/Inc/Communications/Packets/OrderPackets.hpp` + +These headers are generated artifacts, not hand-maintained source files. They are gitignored and should not be edited or committed. + ## 4. Debug from VSCode Launch configurations available in `.vscode/launch.json`: diff --git a/docs/template-project/testing.md b/docs/template-project/testing.md index 297d3ea3..1de593c4 100644 --- a/docs/template-project/testing.md +++ b/docs/template-project/testing.md @@ -56,3 +56,12 @@ For Ethernet/socket stress testing on real hardware, see: - Run strict matrix gate: `./tools/example_tcpip_quality_gate.sh` - Run long soak: `./tools/example_tcpip_soak.sh` - Run multi-hour soak + ratio summary: `./tools/example_tcpip_soak_hours.sh` + +## 6. Packet / Order Parser Validation on Hardware + +For generated `OrderPackets` / `DataPackets` validation on real hardware, see: + +- [`docs/examples/example-packets.md`](../examples/example-packets.md) +- Host-side checker: `./tools/example_packets_check.py --board-ip 192.168.1.7 --host-bind 192.168.1.9` + +`Core/Inc/Communications/Packets/DataPackets.hpp` and `Core/Inc/Communications/Packets/OrderPackets.hpp` are generated from the active `JSON_ADE` schema during build. They are intentionally gitignored and should not be committed. From 3f0959a2f23fb5d11b9f122ab3a46384976c5bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1ez?= Date: Sat, 28 Feb 2026 13:29:29 +0100 Subject: [PATCH 06/11] chore(network): remove the macOS host-link helper from the repo --- docs/examples/example-tcpip.md | 2 + docs/template-project/tcpip-change-manual.md | 12 +- tools/configure_nucleo_host_network_macos.sh | 178 ------------------- tools/run_example_tcpip_nucleo.sh | 9 +- 4 files changed, 11 insertions(+), 190 deletions(-) delete mode 100755 tools/configure_nucleo_host_network_macos.sh diff --git a/docs/examples/example-tcpip.md b/docs/examples/example-tcpip.md index 74574bf8..f5674fd4 100644 --- a/docs/examples/example-tcpip.md +++ b/docs/examples/example-tcpip.md @@ -59,6 +59,8 @@ Main command IDs: ## How to validate +Make sure the host-side board-link interface is configured on `192.168.1.9` before running the stress tools. + Base stress run: ```sh diff --git a/docs/template-project/tcpip-change-manual.md b/docs/template-project/tcpip-change-manual.md index 571f6cbd..9e350747 100644 --- a/docs/template-project/tcpip-change-manual.md +++ b/docs/template-project/tcpip-change-manual.md @@ -362,15 +362,13 @@ Cambios: ## 10. Cambios en scripts de ejecución -### 10.1 Host network seguro en macOS +### 10.1 Suposiciones de red del host -Archivo: `tools/configure_nucleo_host_network_macos.sh` +Qué importa: -Qué añade: - -- prioriza Wi-Fi y mantiene USB-Ethernet para la placa. -- valida ruta default (internet) y ruta a board por interfaz correcta. -- detecta bloqueo de permiso "Local Network" y lo reporta claramente. +- el host debe tener una interfaz en la misma subred que la placa. +- los ejemplos actuales asumen por defecto `192.168.1.9` para el host y `192.168.1.7` para la placa. +- los scripts de test usan `--host-bind` para fijar la IP origen y poder llegar a la placa incluso si la ruta general del sistema apunta por otra interfaz. ### 10.2 Runner end-to-end de Nucleo diff --git a/tools/configure_nucleo_host_network_macos.sh b/tools/configure_nucleo_host_network_macos.sh deleted file mode 100755 index ef802fbe..00000000 --- a/tools/configure_nucleo_host_network_macos.sh +++ /dev/null @@ -1,178 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -service_name="USB 10/100/1000 LAN" -iface="en6" -wifi_service="Wi-Fi" -host_ip="192.168.1.9" -subnet_mask="255.255.255.0" -router_ip="0.0.0.0" -board_ip="192.168.1.7" -check_board=0 -disable_service=0 - -usage() { - cat <<'EOF' -Usage: tools/configure_nucleo_host_network_macos.sh [options] - -Safe host-side setup for running Nucleo Ethernet tests while preserving Wi-Fi. - -Options: - --service Network service name (default: USB 10/100/1000 LAN) - --iface Interface device name (default: en6) - --wifi-service Wi-Fi service name to keep first (default: Wi-Fi) - --host-ip Host IPv4 for the Nucleo link (default: 192.168.1.9) - --mask Host subnet mask (default: 255.255.255.0) - --router Router for USB service (default: 0.0.0.0) - --board-ip Board IPv4 to preflight (default: 192.168.1.7) - --check-board Also run board ping preflight - --disable-service Disable the USB service and exit - -h, --help Show help -EOF -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --service) service_name="$2"; shift 2 ;; - --iface) iface="$2"; shift 2 ;; - --wifi-service) wifi_service="$2"; shift 2 ;; - --host-ip) host_ip="$2"; shift 2 ;; - --mask) subnet_mask="$2"; shift 2 ;; - --router) router_ip="$2"; shift 2 ;; - --board-ip) board_ip="$2"; shift 2 ;; - --check-board) check_board=1; shift ;; - --disable-service) disable_service=1; shift ;; - -h|--help) usage; exit 0 ;; - *) - echo "Unknown option: $1" >&2 - usage - exit 2 - ;; - esac -done - -if ! command -v networksetup >/dev/null 2>&1; then - echo "networksetup not found (macOS required)" >&2 - exit 2 -fi - -if [[ "${disable_service}" -eq 1 ]]; then - networksetup -setnetworkserviceenabled "${service_name}" off - echo "Disabled service: ${service_name}" - exit 0 -fi - -declare -a all_services=() -while IFS= read -r line; do - [[ -z "${line}" ]] && continue - if [[ "${line}" == "An asterisk (*) denotes that a network service is disabled." ]]; then - continue - fi - all_services+=("${line#\*}") -done < <(networksetup -listallnetworkservices) - -if [[ ${#all_services[@]} -eq 0 ]]; then - echo "Could not read network services" >&2 - exit 2 -fi - -found_service=0 -for item in "${all_services[@]}"; do - if [[ "${item}" == "${service_name}" ]]; then - found_service=1 - break - fi -done -if [[ "${found_service}" -ne 1 ]]; then - echo "Service not found: ${service_name}" >&2 - exit 2 -fi - -wifi_found=0 -for item in "${all_services[@]}"; do - if [[ "${item}" == "${wifi_service}" ]]; then - wifi_found=1 - break - fi -done - -if [[ "${wifi_found}" -ne 1 ]]; then - echo "Wi-Fi service not found as '${wifi_service}', preserving current order for other services." >&2 -fi - -declare -a reordered=() -if [[ "${wifi_found}" -eq 1 ]]; then - reordered+=("${wifi_service}") -fi -for item in "${all_services[@]}"; do - if [[ "${item}" == "${service_name}" ]]; then - continue - fi - if [[ "${wifi_found}" -eq 1 && "${item}" == "${wifi_service}" ]]; then - continue - fi - reordered+=("${item}") -done -reordered+=("${service_name}") - -networksetup -ordernetworkservices "${reordered[@]}" -networksetup -setnetworkserviceenabled "${service_name}" on -networksetup -setmanual "${service_name}" "${host_ip}" "${subnet_mask}" "${router_ip}" - -echo "CONFIG service=${service_name} iface=${iface} host_ip=${host_ip} board_ip=${board_ip}" -ifconfig "${iface}" | sed -n '1,80p' -echo "---" -route -n get default || true -echo "---" -route -n get "${board_ip}" || true -echo "---" -scutil --nwi | sed -n '1,120p' - -if ! ifconfig "${iface}" | rg -Fq "inet ${host_ip} "; then - echo "Interface ${iface} does not hold expected IPv4 ${host_ip}" >&2 - exit 3 -fi - -default_if="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')" -if [[ "${default_if}" == "${iface}" ]]; then - echo "Default route moved to ${iface}; this may break Wi-Fi. Aborting." >&2 - exit 4 -fi - -board_route_if="$(route -n get "${board_ip}" 2>/dev/null | awk '/interface:/{print $2; exit}')" -if [[ "${board_route_if}" != "${iface}" ]]; then - echo "Board route is not using ${iface} (got: ${board_route_if:-none})." >&2 - echo "This is expected on macOS when Wi-Fi owns the 192.168.1.0/24 route." >&2 - echo "Host tools should source-bind to ${host_ip} to reach the Nucleo while preserving Wi-Fi." >&2 -fi - -internet_probe="$(ping -c 1 -W 1000 1.1.1.1 || true)" -if ! printf '%s\n' "${internet_probe}" | rg -q "bytes from 1.1.1.1"; then - echo "Internet probe failed after setup; check Wi-Fi/service order." >&2 - exit 6 -fi - -gateway_ip="$(route -n get default 2>/dev/null | awk '/gateway:/{print $2; exit}')" -if [[ -n "${gateway_ip}" ]]; then - local_probe="$(nc -vz -w 2 "${gateway_ip}" 80 2>&1 || true)" - if printf '%s\n' "${local_probe}" | rg -q "No route to host"; then - cat >&2 < Privacy & Security > Local Network -EOF - exit 7 - fi -fi - -if [[ "${check_board}" -eq 1 ]]; then - board_ping="$(ping -S "${host_ip}" -c 3 -W 1000 "${board_ip}" || true)" - printf '%s\n' "${board_ping}" - if ! printf '%s\n' "${board_ping}" | rg -q "bytes from ${board_ip}"; then - echo "Board ping failed for ${board_ip}" >&2 - exit 8 - fi -fi - -echo "HOST_NETWORK_READY" diff --git a/tools/run_example_tcpip_nucleo.sh b/tools/run_example_tcpip_nucleo.sh index ce4811d2..6d471174 100755 --- a/tools/run_example_tcpip_nucleo.sh +++ b/tools/run_example_tcpip_nucleo.sh @@ -102,7 +102,7 @@ fi if [[ -z "${host_ip}" ]]; then echo "Could not determine host IPv4 on interface ${iface}" >&2 - echo "Tip: run tools/configure_nucleo_host_network_macos.sh first or pass --host-ip." >&2 + echo "Tip: configure the host-side board-link interface manually or pass --host-ip." >&2 exit 2 fi @@ -123,7 +123,7 @@ board_route_iface="$(printf '%s\n' "${board_route_output}" | awk '/interface:/{p if [[ "${board_route_iface}" != "${iface}" ]]; then echo "${board_route_output}" echo "Board route does not use ${iface} (got: ${board_route_iface:-none})." >&2 - echo "Proceeding because source-binding to ${host_ip} can still reach the board on macOS." >&2 + echo "Proceeding because source-binding to ${host_ip} can still reach the board even if the OS route points elsewhere." >&2 fi if [[ "${skip_localnet_check}" -eq 0 ]]; then @@ -134,9 +134,8 @@ if [[ "${skip_localnet_check}" -eq 0 ]]; then if printf '%s\n' "${local_probe}" | rg -q "No route to host"; then cat >&2 <<'EOF' Host can reach internet but cannot open local-network routes from this process context. -Likely macOS Local Network privacy block for the app executing this script -(e.g. VS Code / Codex extension host). Enable it in: -System Settings > Privacy & Security > Local Network +The current app or shell process may be blocked from using local-network routes, +or the OS may require an explicit permission for local-network access. EOF exit 2 fi From 2bdc29930c6349a4bfc1030643a41ae94412d76a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1ez?= Date: Sat, 28 Feb 2026 13:52:27 +0100 Subject: [PATCH 07/11] Erased generated chatty doc haha sorry --- docs/examples/example-tcpip.md | 1 - docs/template-project/example-tcpip.md | 4 - docs/template-project/tcpip-change-manual.md | 429 ------------------- docs/template-project/testing.md | 1 - 4 files changed, 435 deletions(-) delete mode 100644 docs/template-project/tcpip-change-manual.md diff --git a/docs/examples/example-tcpip.md b/docs/examples/example-tcpip.md index f5674fd4..7c4c92b9 100644 --- a/docs/examples/example-tcpip.md +++ b/docs/examples/example-tcpip.md @@ -109,4 +109,3 @@ Use `--host-bind 192.168.1.9` when the USB/Ethernet link and the Wi‑Fi are bot For the full command list, telemetry pages, and long-run scripts, see: - [Detailed ExampleTCPIP guide](../template-project/example-tcpip.md) -- [TCP/IP change manual](../template-project/tcpip-change-manual.md) diff --git a/docs/template-project/example-tcpip.md b/docs/template-project/example-tcpip.md index 61d6ed1c..6ae55bd3 100644 --- a/docs/template-project/example-tcpip.md +++ b/docs/template-project/example-tcpip.md @@ -9,10 +9,6 @@ It includes command/control, payload integrity checks (checksum), burst/saturation traffic, forced disconnect/reconnect and UDP probe/ack. -Deep implementation notes for all robustness changes: - -- [`docs/template-project/tcpip-change-manual.md`](tcpip-change-manual.md) - Control command IDs used by the host script: - `CMD_RESET=1` diff --git a/docs/template-project/tcpip-change-manual.md b/docs/template-project/tcpip-change-manual.md deleted file mode 100644 index 9e350747..00000000 --- a/docs/template-project/tcpip-change-manual.md +++ /dev/null @@ -1,429 +0,0 @@ -****# Manual de Cambios TCP/IP (ST-LIB + ExampleTCPIP) - -Este documento explica en detalle lo que se ha cambiado en: - -- `Server` -- `ServerSocket` -- `Socket` -- `DatagramSocket` -- `ExampleTCPIP` -- scripts de test/soak y entorno host - -Objetivo principal de los cambios: - -- reducir fallos intermitentes bajo carga -- hacer recuperación automática ante desconexiones -- eliminar rutas peligrosas de memoria/estado -- mejorar trazabilidad de fallos con telemetría - -## 1. Arquitectura de alto nivel - -### 1.1 Bloques principales - -- `Server` administra varias conexiones TCP entrantes (`ServerSocket`). -- `ServerSocket` representa una conexión TCP aceptada por el server. -- `Socket` representa un cliente TCP saliente (board -> host). -- `DatagramSocket` maneja UDP board <-> host. -- `ExampleTCPIP` orquesta todo y expone comandos de control para tests. - -### 1.2 Flujo operativo en ExampleTCPIP - -- Host envía comandos TCP (`CMD_PING`, `CMD_BURST_SERVER`, etc.). -- Firmware responde por `TCPIP_RESPONSE_ORDER_ID`. -- Se prueban: -- integridad de payload TCP -- ráfagas server->host -- ráfagas client(board)->host -- roundtrip UDP -- desconexión/reconexión forzada - -## 2. Cambios en `OrderProtocol` - -Archivo: `deps/ST-LIB/Inc/HALAL/Models/Packets/OrderProtocol.hpp` - -Cambio: - -- se añadió destructor virtual. - -Por qué: - -- `OrderProtocol` es clase base polimórfica. -- sin destructor virtual, un `delete` vía puntero base puede ser UB/fuga. - -## 3. Cambios en `Server` - -Archivos: - -- `deps/ST-LIB/Inc/ST-LIB_LOW/Communication/Server/Server.hpp` -- `deps/ST-LIB/Src/ST-LIB_LOW/Communication/Server/Server.cpp` - -### 3.1 Gestión de memoria y ciclo de vida - -Antes: - -- había llamadas explícitas a destructores (`obj->~ServerSocket()`), peligrosas. - -Ahora: - -- se usa `delete` real. -- se limpian punteros a `nullptr`. -- se elimina el server de `running_servers` de forma segura. - -### 3.2 Lógica de `update()` más robusta - -Ahora: - -- si `status == CLOSED`, no hace nada. -- si el listener (`open_connection`) no está conectado ni escuchando, se recrea. -- si llega una conexión y hay capacidad, se mueve a `running_connections`. -- si no hay capacidad, se cierra esa nueva conexión sin romper sesiones actuales. -- se compacta el array de conexiones activas y se borran desconectadas. - -Efecto: - -- evita estados zombis. -- evita crecimiento de conexiones inválidas. -- evita caer a fault por una desconexión normal. - -### 3.3 `broadcast_order` ahora devuelve `bool` - -Antes: - -- era `void`; no se podía saber si se envió a alguien. - -Ahora: - -- devuelve `true` si al menos una conexión aceptó el envío. - -Efecto: - -- `ExampleTCPIP` puede decidir si la respuesta realmente salió por el canal esperado. - -## 4. Cambios en `ServerSocket` - -Archivos: - -- `deps/ST-LIB/Inc/HALAL/Services/Communication/Ethernet/LWIP/TCP/ServerSocket.hpp` -- `deps/ST-LIB/Src/HALAL/Services/Communication/Ethernet/LWIP/TCP/ServerSocket.cpp` - -### 4.1 TX queue y envío - -Cambios: - -- `MAX_TX_QUEUE_DEPTH` subido a `64` (antes `24`). -- `send_order()` usa `add_order_to_queue()`. -- si cola llena momentáneamente, intenta un flush (`send()`) y reintenta una vez. - -Efecto: - -- menos falsos fallos bajo ráfagas. -- mejor aprovechamiento del buffer TCP. - -### 4.2 Parsing RX por stream - -Cambio clave: - -- se introdujo `rx_stream_buffer` y parser incremental (`process_order_stream`). - -Por qué: - -- TCP no preserva framing de mensajes. -- un `Order` puede llegar fragmentado o varios `Order` juntos. - -Comportamiento: - -- acumula bytes. -- busca `order_id`. -- valida tamaño. -- procesa solo frames completos. -- resincroniza si encuentra basura/ID desconocido. -- limita buffer a `8192` bytes para no crecer infinito. - -### 4.3 Callbacks lwIP reforzados - -Cambios: - -- comprobaciones nulas en callbacks. -- en errores transitorios de `receive_callback`, no mata conexión agresivamente. -- `error_callback` asume que lwIP ya liberó PCB y limpia estado local. -- `poll_callback` y `send_callback` drenan TX/RX y cierran limpio en `CLOSING`. - -### 4.4 Estados adicionales útiles - -Cambio: - -- nuevo `is_listening() const`. - -Uso: - -- `Server::update()` detecta listener inválido y lo recrea. - -## 5. Cambios en `Socket` (cliente TCP board->host) - -Archivos: - -- `deps/ST-LIB/Inc/HALAL/Services/Communication/Ethernet/LWIP/TCP/Socket.hpp` -- `deps/ST-LIB/Src/HALAL/Services/Communication/Ethernet/LWIP/TCP/Socket.cpp` - -### 5.1 Inicialización y punteros seguros - -Cambios: - -- `connection_control_block` y `socket_control_block` inicializados a `nullptr`. -- limpieza consistente en `close()`, destructor y `operator=`. - -### 5.2 TX queue y envío - -Cambios: - -- `MAX_TX_QUEUE_DEPTH` a `64`. -- `send_order()`: -- verifica `state == CONNECTED`. -- si no conectado, intenta `reconnect()`. -- enqueue con `add_order_to_queue()`. -- flush oportunista + reintento cuando cola momentáneamente llena. - -### 5.3 Parsing RX por stream (igual filosofía que ServerSocket) - -Cambio: - -- `rx_stream_buffer` + parser incremental para soportar fragmentación TCP. - -### 5.4 Reconexión y watchdog de handshake - -Cambios: - -- `pending_connection_reset` y `connect_poll_ticks`. -- `connection_poll_callback()` incrementa ticks cuando queda en `SYN_SENT`. -- si se estanca (`>=20` ticks), marca reset pendiente. -- `reconnect()` dispara `reset()` cuando hay reset pendiente o no hay PCB válido. - -Efecto: - -- menos sockets atascados en handshake. -- recuperación autónoma sin reiniciar firmware. - -### 5.5 Limpieza de código redundante/muerto - -Hecho: - -- se eliminaron rutas de abortado duplicadas tras `close()`. -- se simplificó `connection_error_callback` para limpiar estado sin ramas inútiles. - -## 6. Cambios en `DatagramSocket` (UDP) - -Archivos: - -- `deps/ST-LIB/Inc/HALAL/Services/Communication/Ethernet/LWIP/UDP/DatagramSocket.hpp` -- `deps/ST-LIB/Src/HALAL/Services/Communication/Ethernet/LWIP/UDP/DatagramSocket.cpp` - -### 6.1 Robustez de ciclo de vida - -Cambios: - -- `udp_control_block` inicializado a `nullptr`. -- checks de `udp_new()` y `udp_bind()` con rollback completo. -- `close()` y `reconnect()` idempotentes (si no hay PCB, no rompen). -- move ctor/assignment corregidos para transferencia de ownership real. - -### 6.2 RX seguro con pbuf chain - -Antes: - -- se parseaba directo `packet_buffer->payload` (peligroso si `pbuf` encadenado). - -Ahora: - -- copia con `pbuf_copy_partial()` a buffer continuo. -- parsea solo si tamaño minimo valido. - -Efecto: - -- evita leer memoria incompleta/corrupta en UDP segmentado. - -## 7. Cambios en lwIP config relevantes - -Archivo: `deps/ST-LIB/LWIP/Target/lwipopts.h` - -Cambio: - -- se añadieron macros explícitas: -- `CHECKSUM_GEN_ICMP 0` -- `CHECKSUM_CHECK_ICMP 0` - -Interpretación: - -- coherente con `CHECKSUM_BY_HARDWARE=1` y resto de checksums en `0` (offload HW). - -## 8. Cambios en `ExampleTCPIP` (firmware de pruebas) - -Archivo: `Core/Src/Examples/ExampleTCPIP.cpp` - -### 8.1 Respuesta de control fiable - -Cambio clave: - -- `try_send_tcp_response()` distingue envío por canal cliente vs canal server. -- si hay conexiones server activas, considera éxito solo si respondió por server. - -Por qué: - -- evita “ACK enviado” por el socket cliente cuando el control real iba por server. -- corrige timeouts falsos de comandos en el script. - -### 8.2 Cola de respuestas pendientes - -Cambios: - -- `queue_tcp_response()` + `flush_pending_tcp_response()`. -- no se pierde respuesta si en ese instante no hay ventana TCP disponible. - -### 8.3 Burst server/client con decremento correcto - -Cambio: - -- `server_burst_remaining` y `client_burst_remaining` decrementan solo en envío OK. - -Efecto: - -- evita contar como enviados paquetes que realmente no salieron. - -### 8.4 Backoff/pacing y heartbeats - -Cambios: - -- pacing con `next_*_burst_attempt_ms`. -- heartbeats del cliente solo cuando no hay burst activo. -- reduce interferencia entre tráfico de control y tráfico de estrés. - -### 8.5 Reconexión del `Socket` cliente más fina - -Cambios: - -- `CLIENT_RECONNECT_MS` a `500ms`. -- recreación pesada por contador (`CLIENT_RECONNECT_RECREATE_EVERY=60`) en vez de agresiva. -- watchdog adicional por racha de fallos de envío. - -Efecto: - -- menor inestabilidad en `tcp_client_stream`. - -### 8.6 Health telemetry - -Se añadieron/estandarizaron páginas de health (`CMD_GET_HEALTH`) para diagnosticar: - -- loops, comandos, payload rx/bad -- tx ok/fail del cliente TCP -- conteo de recreaciones y reconexiones -- último motivo de evento -- máximos de burst y ticks desconectado - -## 9. Cambios en `example_tcpip_stress.py` - -Archivo: `tools/example_tcpip_stress.py` - -### 9.1 Matching fuerte de respuestas - -Cambio: - -- `wait_response_matching(...)` permite validar `value0/1/2` esperados. - -Efecto: - -- evita confundir respuestas viejas con respuestas del comando actual. - -### 9.2 Integridad payload más estable - -Cambio: - -- tras enviar payloads, hace polling de stats con ventana de asentamiento. - -Efecto: - -- reduce falsos negativos por latencia de actualización de contadores en firmware. - -### 9.3 Ventanas dinámicas en burst/client-stream - -Cambio: - -- tiempo de colección ajustado al tamaño de burst. - -Efecto: - -- menos sensibilidad a jitter temporal. - -### 9.4 Check client-stream más robusto - -Cambios: - -- nudge de conexión cuando sink está inactivo. -- reintentos controlados. -- validación por ventana agregada (`aggregate_window_pass`) para tolerar ruido de microventanas. - -## 10. Cambios en scripts de ejecución - -### 10.1 Suposiciones de red del host - -Qué importa: - -- el host debe tener una interfaz en la misma subred que la placa. -- los ejemplos actuales asumen por defecto `192.168.1.9` para el host y `192.168.1.7` para la placa. -- los scripts de test usan `--host-bind` para fijar la IP origen y poder llegar a la placa incluso si la ruta general del sistema apunta por otra interfaz. - -### 10.2 Runner end-to-end de Nucleo - -Archivo: `tools/run_example_tcpip_nucleo.sh` - -Qué añade: - -- preflight de rutas y diagnóstico local-network. -- build con `TCPIP_TEST_HOST_IP` y `TCPIP_TEST_BOARD_IP`. -- flash con `stm32prog` o `openocd`. -- fallback de OpenOCD si `verify` falla por mismatch típico de secciones RAM. - -### 10.3 Quality gate y soak - -Archivos: - -- `tools/example_tcpip_quality_gate.sh` -- `tools/example_tcpip_soak.sh` -- `tools/example_tcpip_soak_hours.sh` - -Qué añade: - -- matriz base/agresiva repetible con logs por run. -- preflight ping entre runs. -- soak largo con resumen de ratio PASS/FAIL y breakdown por test fallido. - -## 11. Qué problemas se buscaba solucionar - -Síntomas observados antes: - -- fallos intermitentes en `tcp_client_stream`. -- timeouts puntuales en respuestas de comando (`tcp_server_burst`). -- reconexiones no deterministas. -- posibles riesgos de lifecycle/ownership en sockets. - -Estado tras cambios: - -- mejora clara de estabilidad. -- siguen existiendo fallos aislados bajo soak estricto (baja frecuencia). - -## 12. Riesgos residuales y próximos pasos recomendados - -Pendiente para casi "bulletproof": - -- incluir token de correlación explícito request/response a nivel protocolo de control. -- exponer métricas internas de cola TX y errores lwIP (`ERR_MEM`, `ERR_RST`, etc.) por health page. -- ejecutar soak nocturno y usar baseline de ratio para detectar regresiones automáticamente. - -## 13. Guía rápida de lectura del código - -Orden recomendado para entenderlo sin perderse: - -1. `ExampleTCPIP.cpp` (qué se prueba y cómo se orquesta). -2. `Server.cpp` + `ServerSocket.cpp` (camino de control server-side). -3. `Socket.cpp` (camino board->host y reconexión). -4. `DatagramSocket.cpp` (UDP). -5. `example_tcpip_stress.py` (cómo se verifica desde host). -6. `example_tcpip_quality_gate.sh` / `example_tcpip_soak*.sh` (cómo se automatiza a largo plazo). diff --git a/docs/template-project/testing.md b/docs/template-project/testing.md index 1de593c4..ace90ebe 100644 --- a/docs/template-project/testing.md +++ b/docs/template-project/testing.md @@ -50,7 +50,6 @@ pre-commit run --all-files For Ethernet/socket stress testing on real hardware, see: - [`docs/template-project/example-tcpip.md`](example-tcpip.md) -- Deep-dive implementation manual: [`docs/template-project/tcpip-change-manual.md`](tcpip-change-manual.md) - Per-example quick guides: [`docs/examples/README.md`](../examples/README.md) - One-shot Nucleo flow: `./tools/run_example_tcpip_nucleo.sh` - Run strict matrix gate: `./tools/example_tcpip_quality_gate.sh` From a62b78989b294b309ab26e2758c7acdba7b0f268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1ez?= Date: Sat, 28 Feb 2026 14:39:46 +0100 Subject: [PATCH 08/11] Pointing to proper stlib --- deps/ST-LIB | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/ST-LIB b/deps/ST-LIB index ef1f2561..7a5a1c35 160000 --- a/deps/ST-LIB +++ b/deps/ST-LIB @@ -1 +1 @@ -Subproject commit ef1f2561da687b84581e5f5b61cea1edc5f5d9ab +Subproject commit 7a5a1c35faa32f8c5ae78b7859fd81fdca996efc From 6bed414548020e02a65885f806f17e1ab7b5afbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1ez?= Date: Sat, 28 Feb 2026 14:41:28 +0100 Subject: [PATCH 09/11] Pointing to proper st-lib (again) --- deps/ST-LIB | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/ST-LIB b/deps/ST-LIB index 7a5a1c35..40d92ac5 160000 --- a/deps/ST-LIB +++ b/deps/ST-LIB @@ -1 +1 @@ -Subproject commit 7a5a1c35faa32f8c5ae78b7859fd81fdca996efc +Subproject commit 40d92ac54ed05e8000dc9ec0b251e27a65e14783 From 22b0a4e7bcddd555a0011d1e25744d39c4583886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1ez?= Date: Mon, 2 Mar 2026 21:50:43 +0100 Subject: [PATCH 10/11] pointing to latest stlib --- deps/ST-LIB | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/ST-LIB b/deps/ST-LIB index 40d92ac5..3b1ae1d4 160000 --- a/deps/ST-LIB +++ b/deps/ST-LIB @@ -1 +1 @@ -Subproject commit 40d92ac54ed05e8000dc9ec0b251e27a65e14783 +Subproject commit 3b1ae1d4e48aa9577b88b17857f78fb3d88e0ec7 From 9afd68c7b64eaa6f2edcd4e7c8cf4f28e96310e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20S=C3=A1ez?= Date: Mon, 2 Mar 2026 21:52:58 +0100 Subject: [PATCH 11/11] pointing to latest stlib --- deps/ST-LIB | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deps/ST-LIB b/deps/ST-LIB index 3b1ae1d4..ac84fbcd 160000 --- a/deps/ST-LIB +++ b/deps/ST-LIB @@ -1 +1 @@ -Subproject commit 3b1ae1d4e48aa9577b88b17857f78fb3d88e0ec7 +Subproject commit ac84fbcd6b212351e0c22bde9fd3728032a4f406