From df2fe4542fd994fe694c10a406eb0ae4f5a63c42 Mon Sep 17 00:00:00 2001 From: Torlando Date: Mon, 3 Aug 2026 17:50:13 +0000 Subject: [PATCH] make Reticulum transport mode opt-in --- lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp | 178 +++++++++++++++++- lib/tdeck_ui/UI/LXMF/SettingsScreen.h | 17 +- platformio.ini | 4 +- src/main.cpp | 29 +-- .../test_release_build_contract.py | 2 +- .../test_transport_mode_safety_contract.py | 36 ++++ 6 files changed, 248 insertions(+), 18 deletions(-) create mode 100644 tests/build_scripts/test_transport_mode_safety_contract.py diff --git a/lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp b/lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp index 3d80c4ec..e0a58b1c 100644 --- a/lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp +++ b/lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp @@ -33,6 +33,7 @@ static const char* KEY_TIMEOUT = "timeout"; static const char* KEY_ANNOUNCE_INT = "announce"; static const char* KEY_SYNC_INT = "sync_int"; static const char* KEY_GPS_SYNC = "gps_sync"; +static const char* KEY_TRANSPORT_ENABLED = "transport"; // Notification settings static const char* KEY_NOTIF_SND = "notif_snd"; static const char* KEY_NOTIF_VOL = "notif_vol"; @@ -68,6 +69,7 @@ SettingsScreen::SettingsScreen(lv_obj_t* parent) _slider_lora_power(nullptr), _label_lora_power_value(nullptr), _lora_params_container(nullptr), _switch_auto_enabled(nullptr), _switch_ble_enabled(nullptr), _ta_announce_interval(nullptr), _ta_sync_interval(nullptr), _switch_gps_sync(nullptr), + _switch_transport_enabled(nullptr), _transport_warning_modal(nullptr), _transport_enable_confirmed(false), _btn_propagation_nodes(nullptr), _switch_prop_fallback(nullptr), _switch_prop_only(nullptr), _gps(nullptr) { LVGL_LOCK(); @@ -178,6 +180,8 @@ void SettingsScreen::create_content() { create_gps_section(_content); create_system_section(_content); create_advanced_section(_content); + // This dangerous opt-in must remain the final Settings section. + create_transport_mode_section(_content); } lv_obj_t* SettingsScreen::create_section_header(lv_obj_t* parent, const char* title) { @@ -864,6 +868,127 @@ void SettingsScreen::create_advanced_section(lv_obj_t* parent) { lv_obj_set_style_bg_color(_switch_gps_sync, Theme::primary(), LV_PART_INDICATOR | LV_STATE_CHECKED); } +void SettingsScreen::create_transport_mode_section(lv_obj_t* parent) { + create_section_header(parent, "== DANGER: Transport Mode =="); + + lv_obj_t* warning = lv_label_create(parent); + lv_obj_set_width(warning, LV_PCT(100)); + lv_label_set_long_mode(warning, LV_LABEL_LONG_WRAP); + lv_label_set_text( + warning, + "NOT RECOMMENDED. Transport mode routes other nodes' Reticulum traffic " + "across every enabled interface. It can saturate LoRa airtime, drain the battery, " + "and turn this handheld into network infrastructure. Enable only if you understand " + "Reticulum routing and intend this device to be a transport node. Takes effect after reboot."); + lv_obj_set_style_text_color(warning, Theme::error(), 0); + lv_obj_set_style_text_font(warning, &lv_font_montserrat_12, 0); + lv_obj_set_style_pad_bottom(warning, 4, 0); + + // Keep this row as the final object in Settings so the opt-in cannot be + // mistaken for an ordinary interface switch higher in the screen. + lv_obj_t* transport_row = lv_obj_create(parent); + lv_obj_set_width(transport_row, LV_PCT(100)); + lv_obj_set_height(transport_row, 32); + lv_obj_set_style_bg_opa(transport_row, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(transport_row, 0, 0); + lv_obj_set_style_pad_all(transport_row, 0, 0); + lv_obj_clear_flag(transport_row, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* label = lv_label_create(transport_row); + lv_label_set_text(label, "Enable Transport Node:"); + lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_text_color(label, Theme::error(), 0); + lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0); + + _switch_transport_enabled = lv_switch_create(transport_row); + lv_obj_set_size(_switch_transport_enabled, 40, 20); + lv_obj_align(_switch_transport_enabled, LV_ALIGN_RIGHT_MID, 0, 0); + lv_obj_set_style_bg_color(_switch_transport_enabled, Theme::border(), LV_PART_MAIN); + lv_obj_set_style_bg_color(_switch_transport_enabled, Theme::error(), LV_PART_INDICATOR | LV_STATE_CHECKED); + lv_obj_add_event_cb(_switch_transport_enabled, on_transport_enabled_changed, LV_EVENT_VALUE_CHANGED, this); + + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + lv_group_add_obj(group, _switch_transport_enabled); + } +} + +void SettingsScreen::show_transport_warning() { + if (_transport_warning_modal) return; + + _transport_warning_modal = lv_obj_create(_screen); + lv_obj_set_size(_transport_warning_modal, LV_PCT(100), LV_PCT(100)); + lv_obj_center(_transport_warning_modal); + lv_obj_set_style_bg_color(_transport_warning_modal, lv_color_black(), 0); + lv_obj_set_style_bg_opa(_transport_warning_modal, LV_OPA_80, 0); + lv_obj_set_style_border_width(_transport_warning_modal, 0, 0); + lv_obj_set_style_radius(_transport_warning_modal, 0, 0); + lv_obj_set_style_pad_all(_transport_warning_modal, 8, 0); + lv_obj_clear_flag(_transport_warning_modal, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_add_flag(_transport_warning_modal, LV_OBJ_FLAG_CLICKABLE); + + lv_obj_t* panel = lv_obj_create(_transport_warning_modal); + lv_obj_set_size(panel, LV_PCT(100), LV_PCT(100)); + lv_obj_center(panel); + lv_obj_set_style_bg_color(panel, Theme::surface(), 0); + lv_obj_set_style_border_color(panel, Theme::error(), 0); + lv_obj_set_style_border_width(panel, 2, 0); + lv_obj_set_style_radius(panel, 6, 0); + lv_obj_set_style_pad_all(panel, 8, 0); + lv_obj_clear_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t* title = lv_label_create(panel); + lv_label_set_text(title, "DANGER: Transport Mode"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 0); + lv_obj_set_style_text_color(title, Theme::error(), 0); + lv_obj_set_style_text_font(title, &lv_font_montserrat_16, 0); + + lv_obj_t* text = lv_label_create(panel); + lv_obj_set_width(text, LV_PCT(100)); + lv_label_set_long_mode(text, LV_LABEL_LONG_WRAP); + lv_label_set_text( + text, + "This is NOT RECOMMENDED. The device will relay other nodes' traffic and may " + "saturate LoRa airtime, drain the battery, and disrupt nearby Reticulum users.\n\n" + "Enable only if you understand the consequences and deliberately want this T-Deck " + "to operate as network infrastructure. Takes effect after reboot."); + lv_obj_align(text, LV_ALIGN_TOP_LEFT, 0, 28); + lv_obj_set_style_text_color(text, Theme::textPrimary(), 0); + lv_obj_set_style_text_font(text, &lv_font_montserrat_12, 0); + + lv_obj_t* cancel = lv_btn_create(panel); + lv_obj_set_size(cancel, 100, 32); + lv_obj_align(cancel, LV_ALIGN_BOTTOM_LEFT, 0, 0); + lv_obj_set_style_bg_color(cancel, Theme::btnSecondary(), 0); + lv_obj_add_event_cb(cancel, on_transport_cancel_enable, LV_EVENT_CLICKED, this); + lv_obj_t* cancel_label = lv_label_create(cancel); + lv_label_set_text(cancel_label, "Cancel"); + lv_obj_center(cancel_label); + + lv_obj_t* confirm = lv_btn_create(panel); + lv_obj_set_size(confirm, 145, 32); + lv_obj_align(confirm, LV_ALIGN_BOTTOM_RIGHT, 0, 0); + lv_obj_set_style_bg_color(confirm, Theme::error(), 0); + lv_obj_add_event_cb(confirm, on_transport_confirm_enable, LV_EVENT_CLICKED, this); + lv_obj_t* confirm_label = lv_label_create(confirm); + lv_label_set_text(confirm_label, "ENABLE ANYWAY"); + lv_obj_center(confirm_label); + + lv_group_t* group = LVGL::LVGLInit::get_default_group(); + if (group) { + lv_group_add_obj(group, cancel); + lv_group_add_obj(group, confirm); + lv_group_focus_obj(cancel); + } +} + +void SettingsScreen::close_transport_warning() { + if (!_transport_warning_modal) return; + lv_obj_t* modal = _transport_warning_modal; + _transport_warning_modal = nullptr; + lv_obj_del_async(modal); +} + void SettingsScreen::load_settings() { Preferences prefs; prefs.begin(NVS_NAMESPACE, true); // read-only @@ -876,9 +1001,10 @@ void SettingsScreen::load_settings() { _settings.brightness = prefs.getUChar(KEY_BRIGHTNESS, 180); _settings.keyboard_light = prefs.getBool(KEY_KB_LIGHT, false); _settings.screen_timeout = prefs.getUShort(KEY_TIMEOUT, 60); - _settings.announce_interval = prefs.getUInt(KEY_ANNOUNCE_INT, 3600); // Default 3600s = 1 hour + _settings.announce_interval = prefs.getUInt(KEY_ANNOUNCE_INT, 14400); // Default 14400s = 4 hours _settings.sync_interval = prefs.getUInt(KEY_SYNC_INT, 14400); // Default 14400s = 4 hours _settings.gps_time_sync = prefs.getBool(KEY_GPS_SYNC, true); + _settings.transport_enabled = prefs.getBool(KEY_TRANSPORT_ENABLED, false); // Notification settings _settings.notification_sound = prefs.getBool(KEY_NOTIF_SND, true); @@ -928,6 +1054,7 @@ void SettingsScreen::save_settings() { prefs.putUInt(KEY_ANNOUNCE_INT, _settings.announce_interval); prefs.putUInt(KEY_SYNC_INT, _settings.sync_interval); prefs.putBool(KEY_GPS_SYNC, _settings.gps_time_sync); + prefs.putBool(KEY_TRANSPORT_ENABLED, _settings.transport_enabled); // Notification settings prefs.putBool(KEY_NOTIF_SND, _settings.notification_sound); @@ -1028,6 +1155,16 @@ void SettingsScreen::update_ui_from_settings() { lv_obj_clear_state(_switch_gps_sync, LV_STATE_CHECKED); } } + if (_switch_transport_enabled) { + // Bypass the user-confirmation handler while reflecting persisted state. + _transport_enable_confirmed = true; + if (_settings.transport_enabled) { + lv_obj_add_state(_switch_transport_enabled, LV_STATE_CHECKED); + } else { + lv_obj_clear_state(_switch_transport_enabled, LV_STATE_CHECKED); + } + _transport_enable_confirmed = false; + } // Interface settings if (_switch_tcp_enabled) { @@ -1163,6 +1300,9 @@ void SettingsScreen::update_settings_from_ui() { if (_switch_gps_sync) { _settings.gps_time_sync = lv_obj_has_state(_switch_gps_sync, LV_STATE_CHECKED); } + if (_switch_transport_enabled) { + _settings.transport_enabled = lv_obj_has_state(_switch_transport_enabled, LV_STATE_CHECKED); + } // Interface settings if (_switch_tcp_enabled) { @@ -1384,6 +1524,7 @@ void SettingsScreen::show() { void SettingsScreen::hide() { LVGL_LOCK(); + close_transport_warning(); // Remove from focus group when hiding lv_group_t* group = LVGL::LVGLInit::get_default_group(); if (group) { @@ -1466,6 +1607,41 @@ void SettingsScreen::on_notification_volume_changed(lv_event_t* event) { lv_label_set_text(screen->_label_notification_volume_value, String(volume).c_str()); } +void SettingsScreen::on_transport_enabled_changed(lv_event_t* event) { + SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); + bool enabled = lv_obj_has_state(screen->_switch_transport_enabled, LV_STATE_CHECKED); + + if (!enabled) { + screen->_transport_enable_confirmed = false; + return; + } + + if (screen->_transport_enable_confirmed) { + return; + } + + // Never leave the switch enabled merely because it was toggled. The user + // must complete the explicit second confirmation in the danger dialog. + lv_obj_clear_state(screen->_switch_transport_enabled, LV_STATE_CHECKED); + screen->show_transport_warning(); +} + +void SettingsScreen::on_transport_confirm_enable(lv_event_t* event) { + SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); + screen->_transport_enable_confirmed = true; + lv_obj_add_state(screen->_switch_transport_enabled, LV_STATE_CHECKED); + screen->_transport_enable_confirmed = false; + screen->close_transport_warning(); + INFO("Transport mode opt-in confirmed; save settings and reboot to activate"); +} + +void SettingsScreen::on_transport_cancel_enable(lv_event_t* event) { + SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); + screen->_transport_enable_confirmed = false; + lv_obj_clear_state(screen->_switch_transport_enabled, LV_STATE_CHECKED); + screen->close_transport_warning(); +} + void SettingsScreen::on_propagation_nodes_clicked(lv_event_t* event) { SettingsScreen* screen = (SettingsScreen*)lv_event_get_user_data(event); if (screen->_propagation_nodes_callback) { diff --git a/lib/tdeck_ui/UI/LXMF/SettingsScreen.h b/lib/tdeck_ui/UI/LXMF/SettingsScreen.h index df66764b..28294111 100644 --- a/lib/tdeck_ui/UI/LXMF/SettingsScreen.h +++ b/lib/tdeck_ui/UI/LXMF/SettingsScreen.h @@ -52,9 +52,10 @@ struct AppSettings { bool ble_enabled; // Enable BLE mesh interface // Advanced - uint32_t announce_interval; // seconds (UI shows minutes; default 3600 = 1h) + uint32_t announce_interval; // seconds (UI shows minutes; default 14400 = 4h) uint32_t sync_interval; // seconds (0 = disabled; UI shows hours; default 14400 = 4h) bool gps_time_sync; + bool transport_enabled; // Route traffic for other nodes; default off, requires reboot // Propagation bool prop_auto_select; // Auto-select best propagation node @@ -80,9 +81,10 @@ struct AppSettings { lora_power(17), auto_enabled(false), ble_enabled(false), - announce_interval(3600), + announce_interval(14400), sync_interval(14400), gps_time_sync(true), + transport_enabled(false), prop_auto_select(true), prop_selected_node(""), prop_fallback_enabled(true), @@ -292,6 +294,11 @@ private: lv_obj_t* _ta_sync_interval; lv_obj_t* _switch_gps_sync; + // Dangerous transport-mode section (must remain last in Settings) + lv_obj_t* _switch_transport_enabled; + lv_obj_t* _transport_warning_modal; + bool _transport_enable_confirmed; + // Delivery/Propagation section lv_obj_t* _btn_propagation_nodes; lv_obj_t* _switch_prop_fallback; @@ -321,6 +328,7 @@ private: void create_gps_section(lv_obj_t* parent); void create_system_section(lv_obj_t* parent); void create_advanced_section(lv_obj_t* parent); + void create_transport_mode_section(lv_obj_t* parent); void create_delivery_section(lv_obj_t* parent); // Helpers @@ -344,6 +352,11 @@ private: static void on_lora_power_changed(lv_event_t* event); static void on_propagation_nodes_clicked(lv_event_t* event); static void on_notification_volume_changed(lv_event_t* event); + static void on_transport_enabled_changed(lv_event_t* event); + static void on_transport_confirm_enable(lv_event_t* event); + static void on_transport_cancel_enable(lv_event_t* event); + void show_transport_warning(); + void close_transport_warning(); }; } // namespace LXMF diff --git a/platformio.ini b/platformio.ini index 04cefefe..0787a93e 100644 --- a/platformio.ini +++ b/platformio.ini @@ -97,7 +97,9 @@ lib_deps = ; (_path_table) unpopulated. When bumping past upstream finishing that migration ; (_path_table removed / a for_each API added), DELETE the mirror — see the long ; comment at the _path_table mirror in Transport::inbound for details. - https://github.com/torlando-tech/microReticulum.git#6054f6ba82367628a85cd07fcb668b95e947f046 + ; 1bbd422: initializes persistent path storage for endpoint-only clients too. + ; Path discovery therefore remains usable without enabling transport forwarding. + https://github.com/torlando-tech/microReticulum.git#1bbd422a3b25b4a710642fc5cd01039e5774a4a7 ; microLXMF: chore/microreticulum-0.4.1-layout — includes namespaced to ; for the 0.4.x src/microReticulum/ layout. ; 3cdde79: faster load_message_metadata — single LittleFS open (read_file diff --git a/src/main.cpp b/src/main.cpp index 5eab8f9d..834a1948 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -685,9 +685,10 @@ void load_app_settings() { app_settings.ble_enabled = prefs.getBool("ble_en", false); // Advanced - app_settings.announce_interval = prefs.getULong("announce", 3600); + app_settings.announce_interval = prefs.getULong("announce", 14400); app_settings.sync_interval = prefs.getULong("sync_int", 14400); // Default 14400s = 4 hours app_settings.gps_time_sync = prefs.getBool("gps_sync", true); + app_settings.transport_enabled = prefs.getBool("transport", false); // Propagation app_settings.prop_auto_select = prefs.getBool("prop_auto", true); @@ -1068,18 +1069,15 @@ void setup_reticulum() { // Create Reticulum instance (no auto-init) reticulum = new Reticulum(); - // Enable transport mode so Transport::start() initializes the path - // store. Without this, the entire `_path_store.init()` block at - // Transport.cpp:244 is gated out, _new_path_table.put() always - // returns false at TypedStore::isValid(), and every announce - // surfaces as "Failed to add destination to path table". The UI's - // announce list reads from the path table, so on a busy network - // (TLAN) nothing ever appears. - // - // Transport mode also enables relaying packets for other nodes — - // typically a desktop-class node behavior, but acceptable on a - // T-Deck Plus with PSRAM and LittleFS-backed path persistence. - Reticulum::transport_enabled(true); + // Transport mode makes this handheld route traffic for other nodes across + // every enabled interface. Keep it off unless the user explicitly accepts + // the warning in Settings. Endpoint path persistence does not require it. + Reticulum::transport_enabled(app_settings.transport_enabled); + if (app_settings.transport_enabled) { + WARNING("Reticulum transport mode ENABLED: this device will relay traffic for other nodes"); + } else { + INFO("Reticulum transport mode disabled (endpoint-only)"); + } // Reduce transport log verbosity — LOG_TRACE floods serial with // token/link/announce details that drown out audio diagnostics. @@ -1388,9 +1386,14 @@ void setup_ui_manager() { (new_settings.lora_power != app_settings.lora_power); bool auto_settings_changed = (new_settings.auto_enabled != app_settings.auto_enabled); bool ble_settings_changed = (new_settings.ble_enabled != app_settings.ble_enabled); + bool transport_settings_changed = (new_settings.transport_enabled != app_settings.transport_enabled); app_settings = new_settings; + if (transport_settings_changed) { + WARNING("Transport mode setting changed; reboot required before it takes effect"); + } + // Handle WiFi credential changes - auto reconnect if (wifi_settings_changed && new_settings.wifi_ssid.length() > 0) { INFO(("WiFi credentials changed, reconnecting to: " + new_settings.wifi_ssid).c_str()); diff --git a/tests/build_scripts/test_release_build_contract.py b/tests/build_scripts/test_release_build_contract.py index dc2b61b7..6278f96e 100644 --- a/tests/build_scripts/test_release_build_contract.py +++ b/tests/build_scripts/test_release_build_contract.py @@ -3,7 +3,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] MICROSTORE_PIN = "https://github.com/attermann/microStore.git#c5fb69d68229e684c7fbd17692a67ae8193b84e2" -MICRORETICULUM_PIN = "https://github.com/torlando-tech/microReticulum.git#6054f6ba82367628a85cd07fcb668b95e947f046" +MICRORETICULUM_PIN = "https://github.com/torlando-tech/microReticulum.git#1bbd422a3b25b4a710642fc5cd01039e5774a4a7" def test_microstore_pin_resolves_before_transitive_registry_requirement(): diff --git a/tests/build_scripts/test_transport_mode_safety_contract.py b/tests/build_scripts/test_transport_mode_safety_contract.py new file mode 100644 index 00000000..7dfa5ea6 --- /dev/null +++ b/tests/build_scripts/test_transport_mode_safety_contract.py @@ -0,0 +1,36 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MAIN = (ROOT / "src/main.cpp").read_text() +SETTINGS_H = (ROOT / "lib/tdeck_ui/UI/LXMF/SettingsScreen.h").read_text() +SETTINGS_CPP = (ROOT / "lib/tdeck_ui/UI/LXMF/SettingsScreen.cpp").read_text() + + +def test_transport_mode_defaults_off_and_controls_reticulum_startup(): + assert "bool transport_enabled;" in SETTINGS_H + assert "transport_enabled(false)" in SETTINGS_H + assert 'prefs.getBool("transport", false)' in MAIN + assert MAIN.index("load_app_settings();") < MAIN.index("setup_reticulum();") + assert "Reticulum::transport_enabled(app_settings.transport_enabled);" in MAIN + assert "Reticulum::transport_enabled(true);" not in MAIN + + +def test_transport_mode_setting_is_persisted(): + assert 'KEY_TRANSPORT_ENABLED = "transport"' in SETTINGS_CPP + assert "prefs.getBool(KEY_TRANSPORT_ENABLED, false)" in SETTINGS_CPP + assert "prefs.putBool(KEY_TRANSPORT_ENABLED, _settings.transport_enabled)" in SETTINGS_CPP + + +def test_transport_toggle_is_last_and_requires_explicit_warning_confirmation(): + advanced = SETTINGS_CPP.index("create_advanced_section(_content);") + transport = SETTINGS_CPP.index("create_transport_mode_section(_content);") + assert advanced < transport + + assert "DANGER: Transport Mode" in SETTINGS_CPP + assert "NOT RECOMMENDED" in SETTINGS_CPP + assert "saturate LoRa airtime" in SETTINGS_CPP + assert "drain the battery" in SETTINGS_CPP + assert "ENABLE ANYWAY" in SETTINGS_CPP + assert "Takes effect after reboot" in SETTINGS_CPP + assert "on_transport_enabled_changed" in SETTINGS_CPP + assert "on_transport_confirm_enable" in SETTINGS_CPP