diff --git a/apps/linux_sim_shell/CMakeLists.txt b/apps/linux_sim_shell/CMakeLists.txt index 6cfe2c8f..6f9404e4 100644 --- a/apps/linux_sim_shell/CMakeLists.txt +++ b/apps/linux_sim_shell/CMakeLists.txt @@ -903,6 +903,22 @@ if(BUILD_TESTING) add_test(NAME trailmate_ime_input_mode_descriptor_smoke COMMAND trailmate_ime_input_mode_descriptor_smoke) + add_executable(trailmate_node_info_input_contract_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_node_info_input_contract.cpp") + target_compile_features(trailmate_node_info_input_contract_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_node_info_input_contract_smoke + COMMAND trailmate_node_info_input_contract_smoke + "${TRAIL_MATE_REPO_ROOT}") + + add_executable(trailmate_wifi_connect_backoff_contract_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/tests/test_wifi_connect_backoff_contract.cpp") + target_compile_features(trailmate_wifi_connect_backoff_contract_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_wifi_connect_backoff_contract_smoke + COMMAND trailmate_wifi_connect_backoff_contract_smoke + "${TRAIL_MATE_REPO_ROOT}") + add_executable(trailmate_network_micron_markup_contract_smoke "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_network_micron_markup_contract.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/src/ui/screens/network/micron_markup_contract.cpp") diff --git a/modules/core_chat/tests/test_chat_service_resend.cpp b/modules/core_chat/tests/test_chat_service_resend.cpp index 9f17d2b6..d67eb2cc 100644 --- a/modules/core_chat/tests/test_chat_service_resend.cpp +++ b/modules/core_chat/tests/test_chat_service_resend.cpp @@ -553,6 +553,22 @@ int main() assert(incoming_store.getUnread(broadcast) == 1); assert(incoming_observer.count == 1); + const chat::NodeId direct_sender = 0x5678ABCDU; + const chat::ConversationId direct(chat::ChannelId::PRIMARY, + direct_sender, + chat::MeshProtocol::Meshtastic); + incoming_mesh.pushIncoming(direct_sender, + 0x43U, + "private", + {}, + incoming_mesh.getNodeId()); + incoming_service.processIncoming(); + const auto direct_messages = incoming_store.loadRecent(direct, 10); + assert(direct_messages.size() == 1); + assert(direct_messages.front().peer == direct_sender); + assert(incoming_store.loadRecent(broadcast, 10).size() == 1); + assert(incoming_observer.count == 2); + for (std::uint32_t i = 0; i < 256U; ++i) { incoming_mesh.pushIncoming(0x1234ABCDU, 0x1000U + i, "window fill"); @@ -561,12 +577,12 @@ int main() { incoming_service.processIncoming(); } - assert(incoming_observer.count == 257); + assert(incoming_observer.count == 258); incoming_mesh.pushIncoming(0x1234ABCDU, 0x10FFU, "recent duplicate"); incoming_mesh.pushIncoming(0x1234ABCDU, 0x42U, "evicted original id"); incoming_service.processIncoming(); - assert(incoming_observer.count == 258); + assert(incoming_observer.count == 259); assert(incoming_observer.last_msg_id == 0x42U); } diff --git a/modules/core_sys/include/platform/ui/wifi_access_runtime.h b/modules/core_sys/include/platform/ui/wifi_access_runtime.h index 3a3de257..5d0a3c47 100644 --- a/modules/core_sys/include/platform/ui/wifi_access_runtime.h +++ b/modules/core_sys/include/platform/ui/wifi_access_runtime.h @@ -64,6 +64,12 @@ enum class Decision : std::uint8_t ConnectDeferredForResources, }; +struct ConnectResult +{ + Decision decision = Decision::InvalidRequest; + std::uint32_t retry_after_ms = 0; +}; + enum class ExclusiveOwner : std::uint8_t { None = 0, @@ -147,7 +153,7 @@ bool call_exclusive_active(); bool call_accept_available(); bool set_transport_enabled(bool enabled); -bool ensure_connected(const Request& request, Decision* out_decision = nullptr); +bool ensure_connected(const Request& request, ConnectResult* out_result = nullptr); Lease acquire(const Request& request); void release(const Lease& lease); bool lease_revoked(const Lease& lease); diff --git a/modules/core_sys/tests/test_wifi_connect_backoff_contract.cpp b/modules/core_sys/tests/test_wifi_connect_backoff_contract.cpp new file mode 100644 index 00000000..66eaa569 --- /dev/null +++ b/modules/core_sys/tests/test_wifi_connect_backoff_contract.cpp @@ -0,0 +1,57 @@ +#include +#include +#include +#include +#include + +namespace +{ + +std::string readFile(const std::filesystem::path& path) +{ + std::ifstream stream(path, std::ios::binary); + assert(stream.is_open()); + std::ostringstream out; + out << stream.rdbuf(); + return out.str(); +} + +bool contains(const std::string& haystack, const char* needle) +{ + return haystack.find(needle) != std::string::npos; +} + +} // namespace + +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path repo_root = argv[1]; + const std::string contract = readFile( + repo_root / + "modules/core_sys/include/platform/ui/wifi_access_runtime.h"); + const std::string access_runtime = readFile( + repo_root / + "platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp"); + const std::string mqtt_runtime = readFile( + repo_root / + "platform/esp/arduino_common/src/chat/infra/mesh_mqtt_client_runtime.cpp"); + + assert(contains(contract, "struct ConnectResult")); + assert(contains(contract, "std::uint32_t retry_after_ms = 0;")); + assert(contains(access_runtime, "struct ConnectAttemptWindow")); + assert(contains(access_runtime, + "window.retry_after_ms = kConnectBackoffMs - age_ms;")); + assert(contains(access_runtime, + "if (decision != Decision::Granted && log_denial)")); + + assert(contains(mqtt_runtime, "wifi_retry_not_before_ms_")); + assert(contains( + mqtt_runtime, + "if (deadlinePending(now_ms, wifi_retry_not_before_ms_))")); + assert(contains( + mqtt_runtime, + "now_ms + connect_result.retry_after_ms;")); + + return 0; +} diff --git a/modules/ui_shared/include/ui/screens/node_info/node_info_page_components.h b/modules/ui_shared/include/ui/screens/node_info/node_info_page_components.h index f8715254..5efdc3a1 100644 --- a/modules/ui_shared/include/ui/screens/node_info/node_info_page_components.h +++ b/modules/ui_shared/include/ui/screens/node_info/node_info_page_components.h @@ -55,13 +55,17 @@ struct NodeInfoWidgets lv_obj_t* zoom_status_label = nullptr; lv_obj_t* layer_btn = nullptr; lv_obj_t* layer_label = nullptr; - lv_obj_t* help_btn = nullptr; - lv_obj_t* help_label = nullptr; lv_obj_t* info_labels[kNodeInfoInfoLineCount]{}; ::ui::widgets::map::Widgets map_viewport{}; }; +struct InputCallbacks +{ + void (*back_requested)(void* user_data) = nullptr; + void* user_data = nullptr; +}; + /** * @brief Create the Node Info page widgets. */ @@ -79,9 +83,11 @@ const NodeInfoWidgets& widgets(); /** * Register the complete NodeInfo interaction surface in navigation order. - * Callers no longer need to know which map controls are focusable. + * NodeInfo owns focus registration, page shortcuts, help, and the top-bar + * back action. The host only supplies the navigation callback. */ -void bind_input_group(lv_group_t* group); +void bind_input_group(lv_group_t* group, + const InputCallbacks& callbacks = InputCallbacks{}); /** * @brief Update UI widgets with NodeInfo data. diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp index 937b35d2..0c01d8c5 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp @@ -307,7 +307,6 @@ static void on_del_confirm_clicked(lv_event_t* e); static void on_del_cancel_clicked(lv_event_t* e); static void on_discovery_scan_done(lv_timer_t* timer); static void execute_discovery_command(uint8_t command_index); -static void on_node_info_back_clicked(lv_event_t* e); static void on_node_info_key(lv_event_t* e); static void open_chat_compose(); static void close_chat_compose(); @@ -2519,7 +2518,6 @@ static void open_reticulum_node_info_screen(const chat::contacts::PeerDirectoryI } g_contacts_state.node_info_prev_group = lv_group_get_default(); set_default_group(g_contacts_state.node_info_group); - node_info::ui::bind_input_group(g_contacts_state.node_info_group); if (s_reticulum_node_info_top_bar.back_btn) { lv_group_add_obj(g_contacts_state.node_info_group, s_reticulum_node_info_top_bar.back_btn); @@ -2635,25 +2633,11 @@ static void open_node_info_screen_for_node(uint32_t node_id) g_contacts_state.node_info_prev_group, g_contacts_state.node_info_group); - if (widgets.back_btn) - { - lv_obj_add_event_cb(widgets.back_btn, on_node_info_back_clicked, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(widgets.back_btn, on_node_info_key, LV_EVENT_KEY, nullptr); - CONTACTS_NODE_INFO_LOG("back button wired and focused back_btn=%p\n", widgets.back_btn); - } - lv_obj_t* node_info_controls[] = { - widgets.zoom_out_btn, - widgets.zoom_in_btn, - widgets.layer_btn, - widgets.help_btn, - }; - for (lv_obj_t* control : node_info_controls) - { - if (control) - { - lv_obj_add_event_cb(control, on_node_info_key, LV_EVENT_KEY, nullptr); - } - } + node_info::ui::InputCallbacks input_callbacks{}; + input_callbacks.back_requested = reticulum_node_info_back_requested; + node_info::ui::bind_input_group(g_contacts_state.node_info_group, + input_callbacks); + CONTACTS_NODE_INFO_LOG("node_info input bound back_btn=%p\n", widgets.back_btn); if (g_contacts_state.root) { @@ -3660,12 +3644,6 @@ static void on_del_cancel_clicked(lv_event_t* /*e*/) contacts_focus_to_list(); } -static void on_node_info_back_clicked(lv_event_t* /*e*/) -{ - CONTACTS_NODE_INFO_LOG("back button clicked\n"); - close_node_info_screen(); -} - static void on_node_info_key(lv_event_t* e) { if (lv_event_get_code(e) != LV_EVENT_KEY) diff --git a/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp b/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp index 54112dd1..70844cee 100644 --- a/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp @@ -55,6 +55,7 @@ NodeInfoWidgets s_widgets; ::ui::widgets::TopBar s_top_bar; ::ui::components::shortcut_help_modal::State s_help_modal; lv_group_t* s_input_group = nullptr; +InputCallbacks s_input_callbacks{}; struct NodeInfoRuntimeState { @@ -73,7 +74,17 @@ struct NodeInfoRuntimeState NodeInfoRuntimeState s_state; -void on_help_button_clicked(lv_event_t*) +void consume_key_event(lv_event_t* event) +{ + if (!event) + { + return; + } + lv_event_stop_bubbling(event); + lv_event_stop_processing(event); +} + +void toggle_shortcut_help() { using namespace ::ui::components::shortcut_help_modal; if (is_open(s_help_modal)) @@ -82,12 +93,14 @@ void on_help_button_clicked(lv_event_t*) return; } static constexpr Row kRows[] = { + {"Rotary", nullptr, "Move focus"}, + {"Enter", nullptr, "Use focused control"}, {"-", nullptr, "Zoom out"}, - {"+", nullptr, "Zoom in"}, + {"+", "=", "Zoom in"}, + {"L", nullptr, "Change offline map layer"}, {"Touch", "Drag", "Pan the map"}, - {"Layer", nullptr, "Change offline map layer"}, - {"No tiles", nullptr, "Install offline map files on SD"}, {"Back", nullptr, "Return to Contacts"}, + {"H", nullptr, "Close help"}, }; Config config{}; config.title = "Node Map Help"; @@ -97,6 +110,59 @@ void on_help_button_clicked(lv_event_t*) (void)open(s_help_modal, s_widgets.root, config); } +void request_back() +{ + if (s_input_callbacks.back_requested) + { + s_input_callbacks.back_requested(s_input_callbacks.user_data); + } +} + +void send_control_click(lv_obj_t* control) +{ + if (control && lv_obj_is_valid(control) && + !lv_obj_has_state(control, LV_STATE_DISABLED)) + { + lv_obj_send_event(control, LV_EVENT_CLICKED, nullptr); + } +} + +void on_node_info_input_key(lv_event_t* event) +{ + if (!event || lv_event_get_code(event) != LV_EVENT_KEY) + { + return; + } + + const uint32_t key = lv_event_get_key(event); + if (key == 'h' || key == 'H') + { + toggle_shortcut_help(); + } + else if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) + { + request_back(); + } + else if (key == '-' || key == '_') + { + send_control_click(s_widgets.zoom_out_btn); + } + else if (key == '+' || key == '=') + { + send_control_click(s_widgets.zoom_in_btn); + } + else if (key == 'l' || key == 'L') + { + send_control_click(s_widgets.layer_btn); + } + else + { + return; + } + + consume_key_event(event); +} + struct LayerPopupState { lv_obj_t* bg = nullptr; @@ -1609,20 +1675,6 @@ void position_overlay_widgets() { lv_obj_center(s_widgets.layer_label); } - apply_layer_button_style(s_widgets.help_btn, s_widgets.help_label, metrics.compact); - if (valid_obj(s_widgets.help_btn)) - { - lv_obj_set_size(s_widgets.help_btn, metrics.layer_w, metrics.layer_h); - lv_obj_set_pos(s_widgets.help_btn, - std::max(metrics.pad, - layer_x - metrics.layer_w - - metrics.zoom_gap), - layer_y); - } - if (valid_obj(s_widgets.help_label)) - { - lv_obj_center(s_widgets.help_label); - } log_scene_widgets("position_overlay_widgets"); } @@ -2132,9 +2184,6 @@ NodeInfoWidgets create(lv_obj_t* parent) s_widgets.layer_btn = lv_btn_create(s_widgets.map_stage); s_widgets.layer_label = lv_label_create(s_widgets.layer_btn); lv_label_set_text(s_widgets.layer_label, ::ui::i18n::tr("Layer")); - s_widgets.help_btn = lv_btn_create(s_widgets.map_stage); - s_widgets.help_label = lv_label_create(s_widgets.help_btn); - lv_label_set_text(s_widgets.help_label, ::ui::i18n::tr("Help")); apply_zoom_button_style(s_widgets.zoom_in_btn, s_widgets.zoom_in_label); apply_zoom_button_style(s_widgets.zoom_out_btn, s_widgets.zoom_out_label); apply_layer_button_style(s_widgets.layer_btn, s_widgets.layer_label, view_metrics().compact); @@ -2147,10 +2196,6 @@ NodeInfoWidgets create(lv_obj_t* parent) LV_EVENT_CLICKED, reinterpret_cast(static_cast(-1))); lv_obj_add_event_cb(s_widgets.layer_btn, on_layer_button_clicked, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(s_widgets.help_btn, - on_help_button_clicked, - LV_EVENT_CLICKED, - nullptr); update_zoom_button_state(false); lv_obj_update_layout(s_widgets.root); @@ -2169,6 +2214,7 @@ void destroy() close_layer_popup(); ::ui::components::shortcut_help_modal::close(s_help_modal); s_input_group = nullptr; + s_input_callbacks = InputCallbacks{}; if (s_layer_popup.group) { lv_group_del(s_layer_popup.group); @@ -2193,9 +2239,13 @@ const NodeInfoWidgets& widgets() return s_widgets; } -void bind_input_group(lv_group_t* group) +void bind_input_group(lv_group_t* group, const InputCallbacks& callbacks) { s_input_group = group; + s_input_callbacks = callbacks; + ::ui::widgets::top_bar_set_back_callback(s_top_bar, + callbacks.back_requested, + callbacks.user_data); if (!group) { return; @@ -2206,15 +2256,19 @@ void bind_input_group(lv_group_t* group) s_widgets.zoom_out_btn, s_widgets.zoom_in_btn, s_widgets.layer_btn, - s_widgets.help_btn, }; for (lv_obj_t* control : controls) { if (control && lv_obj_is_valid(control)) { lv_group_add_obj(group, control); + lv_obj_add_event_cb(control, + on_node_info_input_key, + LV_EVENT_KEY, + nullptr); } } + lv_group_set_editing(group, false); if (s_widgets.back_btn && lv_obj_is_valid(s_widgets.back_btn)) { lv_group_focus_obj(s_widgets.back_btn); diff --git a/modules/ui_shared/tests/test_node_info_input_contract.cpp b/modules/ui_shared/tests/test_node_info_input_contract.cpp new file mode 100644 index 00000000..be5d2cbb --- /dev/null +++ b/modules/ui_shared/tests/test_node_info_input_contract.cpp @@ -0,0 +1,67 @@ +#include +#include +#include +#include +#include + +namespace +{ + +std::string readFile(const std::filesystem::path& path) +{ + std::ifstream stream(path, std::ios::binary); + assert(stream.is_open()); + std::ostringstream out; + out << stream.rdbuf(); + return out.str(); +} + +bool contains(const std::string& haystack, const char* needle) +{ + return haystack.find(needle) != std::string::npos; +} + +bool notContains(const std::string& haystack, const char* needle) +{ + return !contains(haystack, needle); +} + +} // namespace + +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path repo_root = argv[1]; + const std::string header = readFile( + repo_root / + "modules/ui_shared/include/ui/screens/node_info/node_info_page_components.h"); + const std::string node_info = readFile( + repo_root / + "modules/ui_shared/src/ui/screens/node_info/node_info_page_components.cpp"); + const std::string contacts = readFile( + repo_root / + "modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp"); + + assert(contains(header, "struct InputCallbacks")); + assert(contains(header, "void (*back_requested)(void* user_data)")); + assert(notContains(header, "help_btn")); + assert(notContains(header, "help_label")); + + assert(contains(node_info, "void on_node_info_input_key")); + assert(contains(node_info, "key == 'h' || key == 'H'")); + assert(contains(node_info, "key == LV_KEY_ESC || key == LV_KEY_BACKSPACE")); + assert(contains(node_info, "key == '-' || key == '_'")); + assert(contains(node_info, "key == '+' || key == '='")); + assert(contains(node_info, "key == 'l' || key == 'L'")); + assert(contains(node_info, "{\"H\", nullptr, \"Close help\"}")); + assert(contains(node_info, "lv_group_set_editing(group, false)")); + assert(contains(node_info, "top_bar_set_back_callback")); + assert(notContains(node_info, "on_help_button_clicked")); + + assert(contains( + contacts, + "node_info::ui::bind_input_group(g_contacts_state.node_info_group,")); + assert(notContains(contacts, "lv_obj_t* node_info_controls[]")); + + return 0; +} diff --git a/platform/esp/arduino_common/src/chat/infra/mesh_mqtt_client_runtime.cpp b/platform/esp/arduino_common/src/chat/infra/mesh_mqtt_client_runtime.cpp index 59aac4e0..058974dd 100644 --- a/platform/esp/arduino_common/src/chat/infra/mesh_mqtt_client_runtime.cpp +++ b/platform/esp/arduino_common/src/chat/infra/mesh_mqtt_client_runtime.cpp @@ -89,6 +89,12 @@ bool elapsed(uint32_t now_ms, uint32_t last_ms, uint32_t interval_ms) return last_ms == 0 || (now_ms - last_ms) >= interval_ms; } +bool deadlinePending(uint32_t now_ms, uint32_t deadline_ms) +{ + return deadline_ms != 0 && + static_cast(deadline_ms - now_ms) > 0; +} + bool isDigits(const char* text) { if (!text || text[0] == '\0') @@ -315,6 +321,7 @@ class PlainMqttRuntime uint32_t last_config_refresh_ms_ = 0; uint32_t last_mqtt_reconnect_ms_ = 0; uint32_t last_io_ms_ = 0; + uint32_t wifi_retry_not_before_ms_ = 0; char address_scratch_[80] = {}; char subscribe_topic_[96] = {}; char publish_topic_[96] = {}; @@ -687,10 +694,17 @@ class PlainMqttRuntime } if (status.connected) { + wifi_retry_not_before_ms_ = 0; logWifiGate(status, WifiGateState::Ready); return true; } + if (deadlinePending(now_ms, wifi_retry_not_before_ms_)) + { + logWifiGate(status, WifiGateState::WaitingForConnection); + return false; + } + std::printf("[%s][MQTT] requesting Wi-Fi access for MQTT ssid=%s\n", protocolTag(), status.ssid[0] ? status.ssid : ""); @@ -702,18 +716,26 @@ class PlainMqttRuntime request.priority = platform::ui::wifi_access::Priority::Messaging; request.allow_connect = true; request.reason = "mqtt"; - platform::ui::wifi_access::Decision decision = - platform::ui::wifi_access::Decision::Granted; - if (platform::ui::wifi_access::ensure_connected(request, &decision)) + platform::ui::wifi_access::ConnectResult connect_result{}; + if (platform::ui::wifi_access::ensure_connected(request, &connect_result)) { + wifi_retry_not_before_ms_ = 0; status = platform::ui::wifi::status(); logWifiGate(status, WifiGateState::Ready); return true; } + if (connect_result.retry_after_ms > 0) + { + wifi_retry_not_before_ms_ = + now_ms + connect_result.retry_after_ms; + } status = platform::ui::wifi::status(); - std::printf("[%s][MQTT] Wi-Fi access denied decision=%s state=%u message='%s'\n", + std::printf("[%s][MQTT] Wi-Fi access denied decision=%s retry_after_ms=%lu state=%u message='%s'\n", protocolTag(), - platform::ui::wifi_access::decision_name(decision), + platform::ui::wifi_access::decision_name( + connect_result.decision), + static_cast( + connect_result.retry_after_ms), static_cast(status.state), status.message); logWifiGate(status, WifiGateState::WaitingForConnection); @@ -903,6 +925,7 @@ class PlainMqttRuntime #else (void)reason; #endif + wifi_retry_not_before_ms_ = 0; resetConnectionState(); } diff --git a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp index 72f9a632..ccc70583 100644 --- a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp +++ b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp @@ -545,12 +545,13 @@ bool WifiGatewayReticulumInterface::ensureSocket() request.priority = platform::ui::wifi_access::Priority::Messaging; request.allow_connect = true; request.reason = "reticulum_gateway"; - platform::ui::wifi_access::Decision decision = - platform::ui::wifi_access::Decision::Granted; - if (!platform::ui::wifi_access::ensure_connected(request, &decision)) + platform::ui::wifi_access::ConnectResult connect_result{}; + if (!platform::ui::wifi_access::ensure_connected(request, + &connect_result)) { Serial.printf("[Reticulum][IF][WiFi] station denied decision=%s host=%s:%u\n", - platform::ui::wifi_access::decision_name(decision), + platform::ui::wifi_access::decision_name( + connect_result.decision), host_, static_cast(port_)); } diff --git a/platform/esp/arduino_common/src/platform_ui_http_client_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_http_client_runtime.cpp index 9e4d770f..3634a676 100644 --- a/platform/esp/arduino_common/src/platform_ui_http_client_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_http_client_runtime.cpp @@ -274,11 +274,11 @@ bool download(const Request& request, return false; } - wifi_access::Decision connect_decision = wifi_access::Decision::Granted; - if (!wifi_access::ensure_connected(access_request, &connect_decision)) + wifi_access::ConnectResult connect_result{}; + if (!wifi_access::ensure_connected(access_request, &connect_result)) { wifi_access::release(lease); - out_error = wifi_access::decision_name(connect_decision); + out_error = wifi_access::decision_name(connect_result.decision); return false; } if (!require_memory_preflight(request, "pre-init low memory", out_error)) diff --git a/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp index 6540b852..bb51e089 100644 --- a/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp @@ -193,19 +193,31 @@ ScreenPhase sample_screen_phase(std::uint32_t now_ms) return ScreenPhase::ScreenOn; } -bool mark_connect_attempt(Client client, std::uint32_t now_ms) +struct ConnectAttemptWindow +{ + bool allowed = false; + std::uint32_t retry_after_ms = 0; +}; + +ConnectAttemptWindow mark_connect_attempt(Client client, std::uint32_t now_ms) { (void)client; - bool allowed = false; + ConnectAttemptWindow window{}; portENTER_CRITICAL(&s_lock); const std::uint32_t last = s_state.last_connect_attempt_ms; - if (last == 0 || (now_ms - last) >= kConnectBackoffMs) + const std::uint32_t age_ms = now_ms - last; + if (last == 0 || age_ms >= kConnectBackoffMs) { s_state.last_connect_attempt_ms = now_ms; - allowed = true; + window.allowed = true; + window.retry_after_ms = kConnectBackoffMs; + } + else + { + window.retry_after_ms = kConnectBackoffMs - age_ms; } portEXIT_CRITICAL(&s_lock); - return allowed; + return window; } bool ota_active_for_other(const Request& request) @@ -655,11 +667,13 @@ bool set_transport_enabled(bool enabled) return clients_ready; } -bool ensure_connected(const Request& request, Decision* out_decision) +bool ensure_connected(const Request& request, ConnectResult* out_result) { const std::uint32_t now_ms = sys::millis_now(); const ScreenPhase phase = sample_screen_phase(now_ms); Decision decision = Decision::Granted; + std::uint32_t retry_after_ms = 0; + bool log_denial = true; if (!transport_enabled()) { @@ -725,39 +739,53 @@ bool ensure_connected(const Request& request, Decision* out_decision) { decision = Decision::ConnectDeferredForWake; } - else if (!mark_connect_attempt(request.client, now_ms)) - { - decision = Decision::ConnectBackoff; - } - else if (!::platform::ui::wifi::connect(nullptr)) - { - decision = - ::platform::ui::wifi::status().state == - ::platform::ui::wifi::ConnectionState::ResourceDeferred - ? Decision::ConnectDeferredForResources - : Decision::ConnectFailed; - } else { - // ESP-IDF P4 targets delegate Wi-Fi to the C6 companion. A - // successfully queued Connect command completes asynchronously; - // absence of an immediate GOT_IP event is not a failed command. - decision = ::platform::ui::wifi::status().connected ? Decision::Granted - : Decision::ConnectBackoff; + const ConnectAttemptWindow window = + mark_connect_attempt(request.client, now_ms); + retry_after_ms = window.retry_after_ms; + if (!window.allowed) + { + decision = Decision::ConnectBackoff; + log_denial = false; + } + else if (!::platform::ui::wifi::connect(nullptr)) + { + decision = + ::platform::ui::wifi::status().state == + ::platform::ui::wifi::ConnectionState::ResourceDeferred + ? Decision::ConnectDeferredForResources + : Decision::ConnectFailed; + } + else + { + // ESP-IDF P4 targets delegate Wi-Fi to the C6 companion. A + // successfully queued Connect command completes asynchronously; + // absence of an immediate GOT_IP event is not a failed command. + decision = ::platform::ui::wifi::status().connected + ? Decision::Granted + : Decision::ConnectBackoff; + } } } - if (out_decision) + if (decision == Decision::Granted) { - *out_decision = decision; + retry_after_ms = 0; } - if (decision != Decision::Granted) + if (out_result) { - std::printf("[WiFiAccess] connect denied client=%s kind=%s phase=%s decision=%s reason=%s\n", + out_result->decision = decision; + out_result->retry_after_ms = retry_after_ms; + } + if (decision != Decision::Granted && log_denial) + { + std::printf("[WiFiAccess] connect denied client=%s kind=%s phase=%s decision=%s retry_after_ms=%lu reason=%s\n", client_name(request.client), access_kind_name(request.kind), screen_phase_name(phase), decision_name(decision), + static_cast(retry_after_ms), request.reason ? request.reason : ""); } return decision == Decision::Granted; diff --git a/platform/esp/idf_common/src/platform_ui_http_client_runtime.cpp b/platform/esp/idf_common/src/platform_ui_http_client_runtime.cpp index 1bc7f75d..7a73bd69 100644 --- a/platform/esp/idf_common/src/platform_ui_http_client_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_http_client_runtime.cpp @@ -1224,10 +1224,10 @@ bool download(const Request& request, } bool ok = false; - wifi_access::Decision decision = wifi_access::Decision::Granted; - if (!wifi_access::ensure_connected(access_request, &decision)) + wifi_access::ConnectResult connect_result{}; + if (!wifi_access::ensure_connected(access_request, &connect_result)) { - out_error = wifi_access::decision_name(decision); + out_error = wifi_access::decision_name(connect_result.decision); } else if (!memory_ready(request)) {