diff --git a/.claude/rules/lvgl.md b/.claude/rules/lvgl.md index bc7bcc206..7a4b407a5 100644 --- a/.claude/rules/lvgl.md +++ b/.claude/rules/lvgl.md @@ -9,6 +9,15 @@ Two kernel modules cover LVGL: - **`lvgl-window-manager-module`** (`Modules/lvgl-window-manager-module/`, ``) manages a single stacked window per app instance on top of `lvgl-module`. `window_manager_start()`/`window_manager_stop()` create/tear down the root widget (plus optional chrome from a configured `WindowManagerScreenInitFn`); `window_manager_create()`/`window_manager_remove()` push/pop an app's window and (re)populate it via a `WindowCreateWidgetsFn`. Only the topmost window ever has live widgets - burying and resurfacing a window deletes and rebuilds its widget tree rather than hiding/showing it. That rebuild-on-remove path can run `create_widgets` on a *different* app's thread (whichever app's `window_manager_remove()` call caused this window to resurface), so `create_widgets` must only rebuild already-committed state, never decide what happens next - state transitions belong in the app's own `main()` event loop, driven by real `APP_EVENT_RESULT`s. Widget pointers an app keeps become dangling once its window is buried (e.g. by a dialog it started). Use `window_manager_create_ext()` with a `destroy_widgets` callback that clears them, and skip widget updates from the app's own thread while they're cleared. +## No file operations on the LVGL thread + +Code that runs on the LVGL thread must not do file operations (including reading or writing settings): `create_widgets`, LVGL event callbacks, timers, and anything under the LVGL lock. +A file read can block the whole UI, and `create_widgets` can run on another app's thread with a small stack, or a stack in external RAM that can't access flash. + +- Do the file work in the app's own task and keep the results in the app's context. `create_widgets` and update functions only show what's cached. +- Callbacks signal the app's task, e.g. with an event group bit. +- The same applies to calls that block on a service, e.g. `mesh_*` calls that wait for the service thread. + ## Keyboard navigation - Put groups of related focusable widgets (pills/chips, rows of buttons, toggle tiles) in a grid navigation container with `lvgl_grid_navigation_add()` (`lvgl/grid_navigation.h`). Only the container joins the input group: left/right moves within the group and up/down moves between groups. diff --git a/CMakeLists.txt b/CMakeLists.txt index 8b4917a74..a4464f129 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,6 +90,12 @@ if (DEFINED ENV{ESP_IDF_VERSION}) # elf_loader's Xtensa relocation drops GLOB_DAT/JMP_SLOT addends. See Modules/app-esp32-module/source/elf_relocate.cpp. idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=esp_elf_arch_relocate" APPEND) + # Starting a netif twice asserts in lwip, and the co-processor can send the station start event more than once. + # See Platforms/platform-esp32/source/drivers/esp32_wifi.cpp. + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=esp_netif_action_start" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=esp_netif_action_stop" APPEND) + idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=esp_netif_destroy" APPEND) + # exit() from an app ends only that app (see app_scheduler_exit_current()) instead of aborting. idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=exit" APPEND) diff --git a/Data/system/fonts/MaterialSymbolsRounded.codepoints b/Data/system/fonts/MaterialSymbolsRounded.codepoints index d05798477..e6288c7b1 100644 --- a/Data/system/fonts/MaterialSymbolsRounded.codepoints +++ b/Data/system/fonts/MaterialSymbolsRounded.codepoints @@ -69,6 +69,7 @@ network_wifi_2_bar ebd6 network_wifi_3_bar ebe1 note_add e89c palette e40a +person_remove ef66 phone_android f2db play_arrow e037 power_settings_new f8c7 diff --git a/Data/system/fonts/MaterialSymbolsRounded.ttf b/Data/system/fonts/MaterialSymbolsRounded.ttf index 78bd51939..61871e129 100644 Binary files a/Data/system/fonts/MaterialSymbolsRounded.ttf and b/Data/system/fonts/MaterialSymbolsRounded.ttf differ diff --git a/Devices/cl32-v04/device.properties b/Devices/cl32-v04/device.properties index 2104eb809..0e18f75a0 100644 --- a/Devices/cl32-v04/device.properties +++ b/Devices/cl32-v04/device.properties @@ -10,7 +10,6 @@ hardware.spiRamXipDisabled=true hardware.esptoolFlashFreq=80M hardware.bluetooth=true hardware.tinyUsbMsc=true -hardware.mesh=true storage.userDataLocation=SD diff --git a/Devices/lilygo-tdeck-max/device.properties b/Devices/lilygo-tdeck-max/device.properties index 483c24698..c592f034a 100644 --- a/Devices/lilygo-tdeck-max/device.properties +++ b/Devices/lilygo-tdeck-max/device.properties @@ -12,7 +12,6 @@ hardware.spiRamSpeed=80M hardware.tinyUsbMsc=true hardware.esptoolFlashFreq=80M hardware.bluetooth=true -hardware.mesh=true # Internal until the SD card is verified working on this board; switch to SD then. storage.userDataLocation=Internal diff --git a/Devices/lilygo-tdeck-plus/device.properties b/Devices/lilygo-tdeck-plus/device.properties index 1d38fb492..af98ca4ef 100644 --- a/Devices/lilygo-tdeck-plus/device.properties +++ b/Devices/lilygo-tdeck-plus/device.properties @@ -10,7 +10,6 @@ hardware.spiRamXipDisabled=true hardware.tinyUsbMsc=true hardware.esptoolFlashFreq=120M hardware.bluetooth=true -hardware.mesh=true storage.userDataLocation=SD diff --git a/Devices/lilygo-tdeck-pro/device.properties b/Devices/lilygo-tdeck-pro/device.properties index 7f5cd169f..f20b775aa 100644 --- a/Devices/lilygo-tdeck-pro/device.properties +++ b/Devices/lilygo-tdeck-pro/device.properties @@ -12,7 +12,6 @@ hardware.spiRamSpeed=80M hardware.tinyUsbMsc=true hardware.esptoolFlashFreq=80M hardware.bluetooth=true -hardware.mesh=true storage.userDataLocation=SD diff --git a/Devices/lilygo-tlora-pager/device.properties b/Devices/lilygo-tlora-pager/device.properties index bd4b6095b..0d6bceb6d 100644 --- a/Devices/lilygo-tlora-pager/device.properties +++ b/Devices/lilygo-tlora-pager/device.properties @@ -10,7 +10,6 @@ hardware.spiRamSpeed=120M hardware.tinyUsbMsc=true hardware.esptoolFlashFreq=40M hardware.bluetooth=true -hardware.mesh=true storage.userDataLocation=SD diff --git a/Devices/seeed-wio-tracker-l2-pro/device.properties b/Devices/seeed-wio-tracker-l2-pro/device.properties index 27fd101cf..054d80fa8 100644 --- a/Devices/seeed-wio-tracker-l2-pro/device.properties +++ b/Devices/seeed-wio-tracker-l2-pro/device.properties @@ -9,7 +9,6 @@ hardware.spiRamSpeed=80M hardware.esptoolFlashFreq=80M hardware.tinyUsbMsc=true hardware.bluetooth=true -hardware.mesh=true storage.userDataLocation=SD diff --git a/Documentation/ideas.md b/Documentation/ideas.md index 49422cd96..b12c6ac0b 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -7,6 +7,7 @@ ## Higher Priority +- Experiment with LVGL stack in SPIRAM - LVGL shouldn't do file IO anyway. - Experiment with default malloc() to SPIRAM - GPIO app: fix for monochrome display by using filled vs outline box in secondary colour - BlueTooth as radio backend for MeshCore/Meshtastic? @@ -36,6 +37,7 @@ ## Medium Priority +- A device profile selection screen for Simulator: select device profiles such as T-Lora Pager, T-Deck Plus, CL-32, Cardputer and T-Embed which changes the resolution and theming defaults after restarting. - A fullscreen tiny keyboard that works with single stroke gestures (similar to Palm OS Graffiti) - CL-32 display driver performance - Move more window management code from Tactility/ to window-manager diff --git a/Drivers/mock-drivers-module/source/mock_radio.cpp b/Drivers/mock-drivers-module/source/mock_radio.cpp index e38bfd7b5..75f4fa00a 100644 --- a/Drivers/mock-drivers-module/source/mock_radio.cpp +++ b/Drivers/mock-drivers-module/source/mock_radio.cpp @@ -16,7 +16,7 @@ namespace { -constexpr int PARAMETER_COUNT = RADIO_PARAMETER_LBT_RSSI_THRESHOLD + 1; +constexpr int PARAMETER_COUNT = RADIO_PARAMETER_CHANNEL + 1; constexpr float MOCK_RX_RSSI = -60.0f; constexpr float MOCK_RX_SNR = 9.5f; constexpr float MOCK_NOISE_FLOOR = -120.0f; diff --git a/Drivers/sx126x-module/source/module.cpp b/Drivers/sx126x-module/source/module.cpp index f359967d4..8af7f93b7 100644 --- a/Drivers/sx126x-module/source/module.cpp +++ b/Drivers/sx126x-module/source/module.cpp @@ -17,7 +17,7 @@ Module sx126x_module = { .stop = nullptr, .drivers = sx126x_drivers, .symbols = nullptr, - .internal = nullptr + .internal = nullptr, }; } // extern "C" diff --git a/Modules/lvgl-module/CMakeLists.txt b/Modules/lvgl-module/CMakeLists.txt index 9eefa04aa..10c87f7a7 100644 --- a/Modules/lvgl-module/CMakeLists.txt +++ b/Modules/lvgl-module/CMakeLists.txt @@ -52,6 +52,7 @@ list(APPEND REQUIRES_LIST binfont-module lodepng-module lvgl + QRCode ) if (DEFINED ENV{ESP_IDF_VERSION}) diff --git a/Modules/lvgl-module/generate-icons.py b/Modules/lvgl-module/generate-icons.py index 3c5c7d517..72a5a3666 100755 --- a/Modules/lvgl-module/generate-icons.py +++ b/Modules/lvgl-module/generate-icons.py @@ -168,6 +168,7 @@ shared_symbol_code_point_names = [ "music_note", "note_add", "palette", + "person_remove", # Mesh: remove a contact or leave a channel "power_settings_new", # Power off for T-Lora Pager "refresh", # e.g. App Hub reload button "search", diff --git a/Modules/lvgl-module/include/lvgl/icons/names.h b/Modules/lvgl-module/include/lvgl/icons/names.h index ef5ce124e..b39bc6e08 100644 --- a/Modules/lvgl-module/include/lvgl/icons/names.h +++ b/Modules/lvgl-module/include/lvgl/icons/names.h @@ -9,7 +9,7 @@ extern "C" { #endif /** Changes whenever the icon TTF subset, its codepoints file or the icon name lists change */ -#define LVGL_ICON_FONT_VERSION 0x3F505982u +#define LVGL_ICON_FONT_VERSION 0x63DDEF79u // Icon names per icon font, resolved through the .codepoints file next to the TTF extern const char* const lvgl_icon_shared_names[]; diff --git a/Modules/lvgl-module/include/lvgl/icons/shared.h b/Modules/lvgl-module/include/lvgl/icons/shared.h index 5f16a9cae..33f8b0f11 100644 --- a/Modules/lvgl-module/include/lvgl/icons/shared.h +++ b/Modules/lvgl-module/include/lvgl/icons/shared.h @@ -41,6 +41,7 @@ #define LVGL_ICON_SHARED_MUSIC_NOTE "\xEE\x90\x85" #define LVGL_ICON_SHARED_NOTE_ADD "\xEE\xA2\x9C" #define LVGL_ICON_SHARED_PALETTE "\xEE\x90\x8A" +#define LVGL_ICON_SHARED_PERSON_REMOVE "\xEE\xBD\xA6" #define LVGL_ICON_SHARED_POWER_SETTINGS_NEW "\xEF\xA3\x87" #define LVGL_ICON_SHARED_REFRESH "\xEE\x97\x95" #define LVGL_ICON_SHARED_SEARCH "\xEE\xBD\xBA" diff --git a/Modules/lvgl-module/include/lvgl/lvgl.h b/Modules/lvgl-module/include/lvgl/lvgl.h index 8e1f0572f..41b7f059e 100644 --- a/Modules/lvgl-module/include/lvgl/lvgl.h +++ b/Modules/lvgl-module/include/lvgl/lvgl.h @@ -41,6 +41,18 @@ bool lvgl_try_lock(uint32_t timeout); */ void lvgl_unlock(void); +/** + * @brief Runs a function with the LVGL lock held, on the LVGL task when the calling task can't do the work itself. + * + * Code that creates widgets can read files, e.g. to decode an image. Reading from flash needs a stack in + * internal RAM, which tasks with a stack in external RAM don't have. Such tasks have the function run on the LVGL + * task, and wait for it to finish. Other tasks run it themselves. + * Don't call this while holding a lock that LVGL callbacks can wait for. + * @param[in] function the function to run + * @param[in] argument passed to @a function + */ +void lvgl_run_on_task(void (*function)(void* argument), void* argument); + /** * @brief Checks if the LVGL module is currently running. * diff --git a/Modules/lvgl-module/include/lvgl/widgets/qr.h b/Modules/lvgl-module/include/lvgl/widgets/qr.h new file mode 100644 index 000000000..44a167e65 --- /dev/null +++ b/Modules/lvgl-module/include/lvgl/widgets/qr.h @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Creates a QR code view. It draws the code with square modules of a whole number of pixels, + * centered in the object and surrounded by the quiet zone that scanners need. + * Give it a size, and set the data with lvgl_qr_set_data(). + * @param[in] parent the parent object for the new view + * @return the created view + */ +lv_obj_t* lvgl_qr_create(lv_obj_t* parent); + +/** + * @brief Sets the text or bytes to encode, in the smallest QR version that fits (low error correction). + * The view keeps showing the previous code when this fails. + * @param[in] obj a view created with lvgl_qr_create() + * @param[in] data the NUL-terminated data, at most 1273 bytes + * @retval ERROR_NONE on success + * @retval ERROR_OUT_OF_RANGE when the data doesn't fit in a QR code + * @retval ERROR_OUT_OF_MEMORY when the code couldn't be allocated + */ +error_t lvgl_qr_set_data(lv_obj_t* obj, const char* data); + +/** + * @brief Sets the colors of the modules and of the background, including the quiet zone. + * Scanners need a dark code on a light background, so these are black and white by default. + */ +void lvgl_qr_set_colors(lv_obj_t* obj, lv_color_t dark, lv_color_t light); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-module/source/arch/lvgl_esp32.c b/Modules/lvgl-module/source/arch/lvgl_esp32.c index 45f95f6a3..7f7d662e5 100644 --- a/Modules/lvgl-module/source/arch/lvgl_esp32.c +++ b/Modules/lvgl-module/source/arch/lvgl_esp32.c @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 #ifdef ESP_PLATFORM +#include +#include #include #include @@ -11,6 +13,12 @@ #include #include +#include +#include +#include + +#include + #define TAG "lvgl_esp32" extern struct LvglModuleConfig lvgl_module_config; @@ -35,6 +43,50 @@ void lvgl_unlock(void) { lvgl_port_unlock(); } +struct RunOnTaskRequest { + void (*function)(void*); + void* argument; + SemaphoreHandle_t done; +}; + +// Runs inside the LVGL task, which holds the LVGL lock +static void run_on_task_callback(void* data) { + struct RunOnTaskRequest* request = data; + request->function(request->argument); + xSemaphoreGive(request->done); +} + +void lvgl_run_on_task(void (*function)(void*), void* argument) { + const int stack_marker = 0; + const bool is_lvgl_task = strcmp(pcTaskGetName(NULL), "taskLVGL") == 0; + SemaphoreHandle_t done = NULL; + // A stack in internal RAM can read files, and the LVGL task itself can always run the function + if (initialized && !is_lvgl_task && !esp_ptr_internal(&stack_marker)) { + done = xSemaphoreCreateBinary(); + } + if (done == NULL) { + lvgl_lock(); + function(argument); + lvgl_unlock(); + return; + } + + struct RunOnTaskRequest request = { .function = function, .argument = argument, .done = done }; + lvgl_lock(); + lv_async_call(run_on_task_callback, &request); + lvgl_unlock(); + // The LVGL task may be asleep for a while otherwise + lvgl_port_task_wake(LVGL_PORT_EVENT_USER, NULL); + while (xSemaphoreTake(done, pdMS_TO_TICKS(1000)) != pdTRUE) { + if (!initialized) { + // LVGL stopped before it ran the function + lv_async_call_cancel(run_on_task_callback, &request); + break; + } + } + vSemaphoreDelete(done); +} + error_t lvgl_arch_start() { const lvgl_port_cfg_t lvgl_cfg = { .task_priority = lvgl_module_config.task_priority, diff --git a/Modules/lvgl-module/source/arch/lvgl_posix.c b/Modules/lvgl-module/source/arch/lvgl_posix.c index 673dd0b8e..5260209b8 100644 --- a/Modules/lvgl-module/source/arch/lvgl_posix.c +++ b/Modules/lvgl-module/source/arch/lvgl_posix.c @@ -62,6 +62,12 @@ void lvgl_unlock(void) { recursive_mutex_unlock(&lvgl_mutex); } +void lvgl_run_on_task(void (*function)(void*), void* argument) { + lvgl_lock(); + function(argument); + lvgl_unlock(); +} + static void lvgl_task_set_interrupted(bool interrupted) { task_lock(); lvgl_task_interrupt_requested = interrupted; diff --git a/Modules/lvgl-module/source/icon_names.c b/Modules/lvgl-module/source/icon_names.c index 76d76f6d7..4081c500a 100644 --- a/Modules/lvgl-module/source/icon_names.c +++ b/Modules/lvgl-module/source/icon_names.c @@ -43,6 +43,7 @@ const char* const lvgl_icon_shared_names[] = { "music_note", "note_add", "palette", + "person_remove", "power_settings_new", "refresh", "search", diff --git a/Modules/lvgl-module/source/symbols.c b/Modules/lvgl-module/source/symbols.c index c7729380e..38f999c66 100644 --- a/Modules/lvgl-module/source/symbols.c +++ b/Modules/lvgl-module/source/symbols.c @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,7 @@ const struct ModuleSymbol lvgl_module_symbols[] = { DEFINE_MODULE_SYMBOL(lvgl_lock), DEFINE_MODULE_SYMBOL(lvgl_try_lock), DEFINE_MODULE_SYMBOL(lvgl_unlock), + DEFINE_MODULE_SYMBOL(lvgl_run_on_task), DEFINE_MODULE_SYMBOL(lvgl_is_running), DEFINE_MODULE_SYMBOL(lvgl_get_ui_density), // lvgl module @@ -148,6 +150,11 @@ const struct ModuleSymbol lvgl_module_symbols[] = { DEFINE_MODULE_SYMBOL(lvgl_list_create), DEFINE_MODULE_SYMBOL(lvgl_list_add_text), DEFINE_MODULE_SYMBOL(lvgl_list_add_button), + // lvgl_qr + DEFINE_MODULE_SYMBOL(lvgl_qr_create), + DEFINE_MODULE_SYMBOL(lvgl_qr_set_data), + DEFINE_MODULE_SYMBOL(lvgl_qr_set_colors), + // lvgl_spinner DEFINE_MODULE_SYMBOL(lvgl_spinner_create), // lvgl_toolbar diff --git a/Modules/lvgl-module/source/widgets/qr.cpp b/Modules/lvgl-module/source/widgets/qr.cpp new file mode 100644 index 000000000..9fcc04ffb --- /dev/null +++ b/Modules/lvgl-module/source/widgets/qr.cpp @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +#define LV_USE_PRIVATE_API 1 // For the lv_obj_t and lv_obj_class_t declarations + +#include + +#include + +extern "C" { +#include +} + +#include +#include + +// Bounds the stack that qrcode_initBytes() uses: its buffers are sized by the QR version +#define QR_MAX_VERSION 25 +// The code needs this many modules of empty space on every side +#define QR_QUIET_ZONE_MODULES 4 + +// Bytes that fit in each version (starting at version 1) with low error correction. +// The QR library doesn't check the length, so the version has to be big enough before encoding. +static const uint16_t QR_VERSION_BYTE_CAPACITY[QR_MAX_VERSION] = { + 17, 32, 53, 78, 106, 134, 154, 192, 230, 271, + 321, 367, 425, 458, 520, 586, 644, 718, 792, 858, + 929, 1003, 1091, 1171, 1273 +}; + +struct QrView { + lv_obj_t obj; + QRCode code; + uint8_t* modules; + lv_color_t dark; + lv_color_t light; +}; + +static void qr_constructor(const lv_obj_class_t* object_class, lv_obj_t* object); +static void qr_destructor(const lv_obj_class_t* object_class, lv_obj_t* object); +static void qr_event(const lv_obj_class_t* object_class, lv_event_t* event); + +static const lv_obj_class_t lvgl_qr_class = { + .base_class = &lv_obj_class, + .constructor_cb = qr_constructor, + .destructor_cb = qr_destructor, + .event_cb = qr_event, + .user_data = nullptr, + .name = "qr", + .width_def = 0, + .height_def = 0, + .editable = LV_OBJ_CLASS_EDITABLE_FALSE, + .group_def = LV_OBJ_CLASS_GROUP_DEF_FALSE, + .instance_size = sizeof(QrView), + .theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE +}; + +static uint8_t* allocate_modules(uint8_t version) { + constexpr MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; + return static_cast(memory_alloc_with_policy(qrcode_getBufferSize(version), &policy)); +} + +static void qr_constructor(const lv_obj_class_t* object_class, lv_obj_t* object) { + auto* view = reinterpret_cast(object); + view->modules = nullptr; + view->dark = lv_color_black(); + view->light = lv_color_white(); + // The view draws its own background and has nothing to scroll + lv_obj_set_scrollable(object, false); + lv_obj_set_style_pad_all(object, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(object, 0, LV_STATE_DEFAULT); + lv_obj_set_style_radius(object, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(object, LV_OPA_TRANSP, LV_STATE_DEFAULT); +} + +static void qr_destructor(const lv_obj_class_t* object_class, lv_obj_t* object) { + auto* view = reinterpret_cast(object); + if (view->modules != nullptr) { + memory_free(view->modules); + view->modules = nullptr; + } +} + +static void qr_draw(lv_obj_t* object, lv_layer_t* layer) { + auto* view = reinterpret_cast(object); + lv_area_t area; + lv_obj_get_content_coords(object, &area); + + lv_draw_rect_dsc_t rect; + lv_draw_rect_dsc_init(&rect); + rect.bg_opa = LV_OPA_COVER; + rect.bg_color = view->light; + lv_draw_rect(layer, &rect, &area); + + if (view->modules == nullptr) { + return; + } + + const int32_t modules_with_quiet_zone = view->code.size + 2 * QR_QUIET_ZONE_MODULES; + const int32_t module_size = std::min(lv_area_get_width(&area), lv_area_get_height(&area)) / modules_with_quiet_zone; + if (module_size < 1) { + return; + } + const int32_t origin_x = area.x1 + (lv_area_get_width(&area) - modules_with_quiet_zone * module_size) / 2 + QR_QUIET_ZONE_MODULES * module_size; + const int32_t origin_y = area.y1 + (lv_area_get_height(&area) - modules_with_quiet_zone * module_size) / 2 + QR_QUIET_ZONE_MODULES * module_size; + + rect.bg_color = view->dark; + // Neighbouring dark modules in a row are drawn as one rectangle + for (int32_t y = 0; y < view->code.size; y++) { + int32_t x = 0; + while (x < view->code.size) { + if (!qrcode_getModule(&view->code, static_cast(x), static_cast(y))) { + x++; + continue; + } + int32_t end = x + 1; + while (end < view->code.size && qrcode_getModule(&view->code, static_cast(end), static_cast(y))) { + end++; + } + lv_area_t run = { + .x1 = origin_x + x * module_size, + .y1 = origin_y + y * module_size, + .x2 = origin_x + end * module_size - 1, + .y2 = origin_y + (y + 1) * module_size - 1, + }; + lv_draw_rect(layer, &rect, &run); + x = end; + } + } +} + +static void qr_event(const lv_obj_class_t* object_class, lv_event_t* event) { + if (lv_obj_event_base(&lvgl_qr_class, event) != LV_RESULT_OK) { + return; + } + if (lv_event_get_code(event) == LV_EVENT_DRAW_MAIN) { + qr_draw(lv_event_get_current_target_obj(event), lv_event_get_layer(event)); + } +} + +lv_obj_t* lvgl_qr_create(lv_obj_t* parent) { + lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_qr_class, parent); + lv_obj_class_init_obj(obj); + return obj; +} + +error_t lvgl_qr_set_data(lv_obj_t* obj, const char* data) { + auto* view = reinterpret_cast(obj); + const size_t length = strlen(data); + if (length > QR_VERSION_BYTE_CAPACITY[QR_MAX_VERSION - 1]) { + return ERROR_OUT_OF_RANGE; + } + + uint8_t version = 1; + while (length > QR_VERSION_BYTE_CAPACITY[version - 1]) { + version++; + } + uint8_t* modules = allocate_modules(version); + if (modules == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + QRCode code; + if (qrcode_initBytes(&code, modules, version, ECC_LOW, reinterpret_cast(const_cast(data)), static_cast(length)) != 0) { + memory_free(modules); + return ERROR_OUT_OF_RANGE; + } + if (view->modules != nullptr) { + memory_free(view->modules); + } + view->modules = modules; + view->code = code; + lv_obj_invalidate(obj); + return ERROR_NONE; +} + +void lvgl_qr_set_colors(lv_obj_t* obj, lv_color_t dark, lv_color_t light) { + auto* view = reinterpret_cast(obj); + view->dark = dark; + view->light = light; + lv_obj_invalidate(obj); +} diff --git a/Modules/lvgl-module/tests/CMakeLists.txt b/Modules/lvgl-module/tests/CMakeLists.txt index 961d96265..66417dffa 100644 --- a/Modules/lvgl-module/tests/CMakeLists.txt +++ b/Modules/lvgl-module/tests/CMakeLists.txt @@ -6,6 +6,9 @@ file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${PROJECT_SOURCE_DIR}/source/*. add_executable(LvglModuleTests EXCLUDE_FROM_ALL ${TEST_SOURCES} ${PROJECT_SOURCE_DIR}/../source/insets_math.cpp + ${PROJECT_SOURCE_DIR}/../source/widgets/qr.cpp + # LVGL allocates through the kernel + ${PROJECT_SOURCE_DIR}/../source/lv_mem_custom.c ) target_include_directories(LvglModuleTests PRIVATE @@ -19,4 +22,5 @@ add_test(NAME LvglModuleTests COMMAND LvglModuleTests) target_link_libraries(LvglModuleTests PUBLIC TactilityKernel lvgl + QRCode ) diff --git a/Modules/lvgl-module/tests/source/qr_test.cpp b/Modules/lvgl-module/tests/source/qr_test.cpp new file mode 100644 index 000000000..7c50ee025 --- /dev/null +++ b/Modules/lvgl-module/tests/source/qr_test.cpp @@ -0,0 +1,25 @@ +#include "doctest.h" + +#include +#include + +#include + +TEST_CASE("lvgl_qr_set_data accepts data that fits in a QR code and rejects data that doesn't") { + lv_init(); + lv_display_create(320, 240); + lv_obj_t* qr = lvgl_qr_create(lv_screen_active()); + + CHECK_EQ(lvgl_qr_set_data(qr, "hello"), ERROR_NONE); + // Longer data needs a larger version + CHECK_EQ(lvgl_qr_set_data(qr, std::string(17, 'a').c_str()), ERROR_NONE); + CHECK_EQ(lvgl_qr_set_data(qr, std::string(18, 'a').c_str()), ERROR_NONE); + CHECK_EQ(lvgl_qr_set_data(qr, std::string(1273, 'a').c_str()), ERROR_NONE); + CHECK_EQ(lvgl_qr_set_data(qr, std::string(1274, 'a').c_str()), ERROR_OUT_OF_RANGE); + CHECK_EQ(lvgl_qr_set_data(qr, std::string(5000, 'a').c_str()), ERROR_OUT_OF_RANGE); + // A failure keeps the previous code + CHECK_EQ(lvgl_qr_set_data(qr, ""), ERROR_NONE); + + lv_obj_delete(qr); + lv_deinit(); +} diff --git a/Modules/lvgl-window-manager-module/source/window_manager.cpp b/Modules/lvgl-window-manager-module/source/window_manager.cpp index 5c0957b8f..e2ec46920 100644 --- a/Modules/lvgl-window-manager-module/source/window_manager.cpp +++ b/Modules/lvgl-window-manager-module/source/window_manager.cpp @@ -139,12 +139,17 @@ void on_overlay_key(lv_event_t* event) { } } -lv_obj_t* build_window_widget(lv_obj_t* content, WindowCreateWidgetsFn create_widgets, void* user_data) { - if (content == nullptr) { - return nullptr; - } - lvgl_lock(); - lv_obj_t* widget = lv_obj_create(content); +struct BuildRequest { + lv_obj_t* content; + WindowCreateWidgetsFn create_widgets; + void* user_data; + lv_obj_t* widget = nullptr; +}; + +// Runs with the LVGL lock held +void build_window_widget_locked(void* argument) { + auto* request = static_cast(argument); + lv_obj_t* widget = lv_obj_create(request->content); lv_obj_set_size(widget, LV_PCT(100), LV_PCT(100)); lv_obj_set_style_pad_all(widget, 0, LV_STATE_DEFAULT); lv_obj_set_style_border_width(widget, 0, LV_STATE_DEFAULT); @@ -153,15 +158,24 @@ lv_obj_t* build_window_widget(lv_obj_t* content, WindowCreateWidgetsFn create_wi // for its own root object. Without it, a sub-pixel flex-layout overflow here can show the // theme's scrollbar styling as a thin line hugging this widget's edges. lv_obj_set_scrollable(widget, false); - if (create_widgets != nullptr) { + if (request->create_widgets != nullptr) { // Windows belong to the app layer, also while the overlay is the active layer lv_group_t* default_group = lv_group_get_default(); lv_group_set_default(lvgl_keyboard_get_app_group()); - create_widgets(widget, user_data); + request->create_widgets(widget, request->user_data); lv_group_set_default(default_group); } - lvgl_unlock(); - return widget; + request->widget = widget; +} + +lv_obj_t* build_window_widget(lv_obj_t* content, WindowCreateWidgetsFn create_widgets, void* user_data) { + if (content == nullptr) { + return nullptr; + } + // The widgets can load files, e.g. images, so tasks without a stack for that have the LVGL task build them + BuildRequest request { content, create_widgets, user_data }; + lvgl_run_on_task(build_window_widget_locked, &request); + return request.widget; } // destroy_widgets, if set, is called inside the same LVGL-locked section as the deletion - see @@ -302,6 +316,18 @@ error_t window_manager_start(void) { if (has_top) { lv_obj_t* new_widget = build_window_widget(content_widget, top_create_widgets, top_user_data); + // The build is canceled when LVGL stopped before it ran. Then the module isn't started. + if (new_widget == nullptr) { + LOG_E(TAG, "Building the topmost window failed"); + mutex_lock(&s.mutex); + s.started = false; + s.real_root_widget = nullptr; + s.content_root_widget = nullptr; + mutex_unlock(&s.mutex); + mutex_unlock(&s.lifecycle_mutex); + return ERROR_RESOURCE; + } + mutex_lock(&s.mutex); bool still_topmost = !s.windows.empty() && s.windows.back().id == top_id; if (still_topmost) { @@ -424,6 +450,18 @@ WindowId window_manager_create_ext(AppInstanceId app_instance_id, WindowCreateWi lv_obj_t* new_widget = build_window_widget(content, create_widgets, user_data); mutex_lock(&s.mutex); + if (new_widget == nullptr) { + // The build is canceled when LVGL stopped before it ran. The window never became active. + auto failed = std::find_if(s.windows.begin(), s.windows.end(), + [new_id](const WindowRecord& window) { return window.id == new_id; }); + if (failed != s.windows.end()) { + s.windows.erase(failed); + } + mutex_unlock(&s.mutex); + LOG_E(TAG, "Building the window failed"); + mutex_unlock(&s.lifecycle_mutex); + return 0; + } bool still_topmost = !s.windows.empty() && s.windows.back().id == new_id; if (still_topmost) { s.top_widget = new_widget; @@ -497,6 +535,14 @@ void window_manager_remove(WindowId id) { delete_widget(old_widget, removed_destroy_widgets, removed_user_data); lv_obj_t* new_widget = has_next ? build_window_widget(content, next_create_widgets, next_user_data) : nullptr; + if (has_next && new_widget == nullptr) { + // The build is canceled when LVGL stopped before it ran. The window has no widget then, and the next + // window_manager_start() builds it again. + LOG_E(TAG, "Building the window that resurfaced failed"); + mutex_unlock(&s.lifecycle_mutex); + return; + } + mutex_lock(&s.mutex); bool still_topmost = has_next && !s.windows.empty() && s.windows.back().id == next_id; if (still_topmost) { diff --git a/Modules/mesh-meshcore-module/README.md b/Modules/mesh-meshcore-module/README.md index c4c5087d1..d48f8c48a 100644 --- a/Modules/mesh-meshcore-module/README.md +++ b/Modules/mesh-meshcore-module/README.md @@ -18,5 +18,8 @@ Radio presets: "Default" is EU_868 (869.618 MHz, 62.5 kHz, SF8, CR 4/8) US_915 ( AU_915 (915.8 MHz, 250 kHz, SF10, CR 4/5). "EU NL" is the Dutch community setting for EU_868: 869.618 MHz, 62.5 kHz, SF7, CR 4/5. Other regions report `ERROR_NOT_SUPPORTED`. +A radio with `RADIO_MODULATION_ESPNOW` (no LoRa) uses MeshCore's ESP-NOW transport in every region, without presets: +unencrypted broadcast frames on Wi-Fi channel 1 at 20 dBm. It reaches other Tactility and MeshCore ESP-NOW nodes in +Wi-Fi range, not LoRa nodes. Not implemented: path learning (direct routing), requests to repeaters and room servers, multi-part messages. diff --git a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h index 53ffbcb8a..cfbccb6de 100644 --- a/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h +++ b/Modules/mesh-meshcore-module/private/mesh_meshcore/meshcore_protocol.h @@ -52,6 +52,14 @@ void meshcore_channel_from_key(const uint8_t key[16], MeshCoreChannel* channel); /** Key of a hashtag channel (e.g. "#test"): the first 16 bytes of the SHA-256 of its name */ void meshcore_hashtag_channel_key(const char* name, uint8_t key[16]); +/** + * Build the URI that MeshCore apps scan as a QR code to add a contact: + * meshcore://contact/add?name=&public_key=&type= + * @param[in] type the contact type: 1 for a companion, 2 for a repeater, 3 for a room server, 4 for a sensor + * @return false when it doesn't fit in @a uri + */ +bool meshcore_build_contact_uri(const char* name, const uint8_t publicKey[MESHCORE_PUBLIC_KEY_SIZE], uint8_t type, char* uri, size_t uriSize); + /** Shared secret between our identity and a peer's public key */ void meshcore_shared_secret(const MeshCoreIdentity& identity, const uint8_t peerPublicKey[MESHCORE_PUBLIC_KEY_SIZE], uint8_t secret[MESHCORE_SECRET_SIZE]); diff --git a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp index d969551fd..d8b944d97 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_backend.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_backend.cpp @@ -348,7 +348,28 @@ constexpr size_t PRESET_EU_NL = 1; const char* const presets[] = { "Default", "EU NL", nullptr }; -error_t getRadioProfile(void*, RadioRegion region, size_t preset, MeshRadioProfile* profile) { +error_t getRadioProfile(void*, RadioRegion region, RadioModulation modulation, size_t preset, MeshRadioProfile* profile) { + // MeshCore's ESP-NOW transport broadcasts unencrypted frames on the Wi-Fi channel that the radio is on, at 20 dBm. + // The region doesn't matter, as the Wi-Fi stack limits the power for the country. + if (modulation == RADIO_MODULATION_ESPNOW) { + *profile = { + .modulation = RADIO_MODULATION_ESPNOW, + .frequency = 0, + .bandwidth = 0, + .spreading_factor = 0, + .coding_rate = 0, + .sync_word = 0, + .preamble_length = 0, + .power = 20, + .channel = 1, + }; + return ERROR_NONE; + } + + if (modulation != RADIO_MODULATION_LORA) { + return ERROR_NOT_SUPPORTED; + } + // MeshCore's recommended presets. Sync word 0x12 is LoRa's private network sync word. switch (region) { case RADIO_REGION_EU_868: @@ -402,6 +423,24 @@ void onStart(void* context) { sendAdvert(static_cast(context)); } +error_t getContactUri(void* context, const MeshAddress* contact, const char* name, char* uri, size_t uriSize) { + auto* backend = static_cast(context); + if (contact != nullptr && contact->length != MESHCORE_PUBLIC_KEY_SIZE) { + return ERROR_INVALID_ARGUMENT; + } + const uint8_t* publicKey = (contact != nullptr) ? contact->bytes : backend->identity.publicKey; + // Contacts are shown as companions, as the type of a contact isn't stored + return meshcore_build_contact_uri(name, publicKey, 1, uri, uriSize) ? ERROR_NONE : ERROR_BUFFER_OVERFLOW; +} + +bool isValidContactAddress(void*, const MeshAddress* address) { + return address->length == MESHCORE_PUBLIC_KEY_SIZE; +} + +void onNodeNameChanged(void* context) { + sendAdvert(static_cast(context)); +} + bool onReceive(void* context, const uint8_t* data, size_t length, float rssi, float snr) { auto* backend = static_cast(context); MeshCorePacket packet; @@ -441,7 +480,7 @@ void onTick(void* context) { } for (auto iterator = backend->pendingAcks.begin(); iterator != backend->pendingAcks.end();) { if (nowMs >= iterator->second.deadlineMs) { - // Sent but never acknowledged: it stays "sent" + mesh_host_set_delivery(backend->host, iterator->second.seq, MESH_DELIVERY_FAILED); iterator = backend->pendingAcks.erase(iterator); } else { ++iterator; @@ -520,6 +559,16 @@ error_t setChannel(void* context, const MeshChannel* channel) { return ERROR_NONE; } +error_t removeChannel(void* context, uint8_t index) { + auto* backend = static_cast(context); + const size_t removed = std::erase_if(backend->channels, [index](const Channel& existing) { return existing.index == index; }); + if (removed == 0) { + return ERROR_NOT_FOUND; + } + saveChannels(backend); + return ERROR_NONE; +} + // endregion } // namespace @@ -536,4 +585,8 @@ const MeshBackendApi meshcore_backend_api = { .send_text = sendText, .get_channels = getChannels, .set_channel = setChannel, + .get_contact_uri = getContactUri, + .is_valid_contact_address = isValidContactAddress, + .on_node_name_changed = onNodeNameChanged, + .remove_channel = removeChannel, }; diff --git a/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp b/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp index a61565263..d980f7428 100644 --- a/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp +++ b/Modules/mesh-meshcore-module/source/meshcore_protocol.cpp @@ -252,3 +252,53 @@ bool meshcore_parse_ack(const MeshCorePacket& packet, uint32_t* ackHash) { *ackHash = readU32(packet.payload); return true; } + +bool meshcore_build_contact_uri(const char* name, const uint8_t publicKey[MESHCORE_PUBLIC_KEY_SIZE], uint8_t type, char* uri, size_t uriSize) { + size_t length = 0; + const auto append = [&](char character) { + if (length + 1 >= uriSize) { + return false; + } + uri[length++] = character; + return true; + }; + const auto appendText = [&](const char* text) { + for (; *text != '\0'; text++) { + if (!append(*text)) { + return false; + } + } + return true; + }; + + static const char HEX_DIGITS[] = "0123456789abcdef"; + if (!appendText("meshcore://contact/add?name=")) { + return false; + } + // Percent-encoded, with a plus for a space like the MeshCore app does + for (const char* character = name; *character != '\0'; character++) { + const auto byte = static_cast(*character); + const bool unreserved = (byte >= 'a' && byte <= 'z') || (byte >= 'A' && byte <= 'Z') || (byte >= '0' && byte <= '9') || + byte == '-' || byte == '.' || byte == '_' || byte == '~'; + const bool ok = unreserved ? append(*character) + : (byte == ' ' ? append('+') : (append('%') && append(HEX_DIGITS[byte >> 4]) && append(HEX_DIGITS[byte & 0x0F]))); + if (!ok) { + return false; + } + } + if (!appendText("&public_key=")) { + return false; + } + for (size_t i = 0; i < MESHCORE_PUBLIC_KEY_SIZE; i++) { + if (!append(HEX_DIGITS[publicKey[i] >> 4]) || !append(HEX_DIGITS[publicKey[i] & 0x0F])) { + return false; + } + } + char suffix[16]; + snprintf(suffix, sizeof(suffix), "&type=%u", (unsigned)type); + if (!appendText(suffix)) { + return false; + } + uri[length] = '\0'; + return true; +} diff --git a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp index 6b8816096..bb4497f01 100644 --- a/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp +++ b/Modules/mesh-meshcore-module/tests/source/protocol_test.cpp @@ -3,7 +3,9 @@ #include #include +#include #include +#include TEST_CASE("MeshCore packets survive a serialize and parse round trip") { MeshCorePacket packet; @@ -138,3 +140,22 @@ TEST_CASE("MeshCore direct messages decrypt at the recipient and produce matchin REQUIRE(meshcore_parse_ack(ack, &parsedAckHash)); CHECK_EQ(parsedAckHash, sentAckHash); } + +TEST_CASE("MeshCore contact URIs match the format of the MeshCore app") { + uint8_t key[MESHCORE_PUBLIC_KEY_SIZE]; + const char* hex = "9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1"; + for (size_t i = 0; i < sizeof(key); i++) { + char pair[3] = { hex[i * 2], hex[i * 2 + 1], '\0' }; + key[i] = (uint8_t)strtoul(pair, nullptr, 16); + } + + char uri[256]; + REQUIRE(meshcore_build_contact_uri("Example Contact", key, 1, uri, sizeof(uri))); + CHECK_EQ(std::string(uri), "meshcore://contact/add?name=Example+Contact&public_key=9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1&type=1"); + + REQUIRE(meshcore_build_contact_uri("Ken's deck&co", key, 2, uri, sizeof(uri))); + CHECK_EQ(std::string(uri).substr(0, 52), "meshcore://contact/add?name=Ken%27s+deck%26co&public"); + + // Too small for the URI + CHECK_FALSE(meshcore_build_contact_uri("Example Contact", key, 1, uri, 60)); +} diff --git a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp index 4b4e0eb5b..83afab673 100644 --- a/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp +++ b/Modules/mesh-meshtastic-module/source/meshtastic_backend.cpp @@ -341,8 +341,11 @@ void destroy(void* context) { tt::deleteObject(static_cast(context)); } -error_t getRadioProfile(void* context, RadioRegion region, size_t, MeshRadioProfile* profile) { +error_t getRadioProfile(void* context, RadioRegion region, RadioModulation modulation, size_t, MeshRadioProfile* profile) { auto* backend = static_cast(context); + if (modulation != RADIO_MODULATION_LORA) { + return ERROR_NOT_SUPPORTED; + } for (const auto& info : REGIONS) { if (info.region != region) { continue; @@ -369,6 +372,14 @@ void onStart(void* context) { sendNodeInfo(static_cast(context)); } +bool isValidContactAddress(void*, const MeshAddress* address) { + return address->length == 4; +} + +void onNodeNameChanged(void* context) { + sendNodeInfo(static_cast(context)); +} + bool onReceive(void* context, const uint8_t* frame, size_t length, float rssi, float snr) { auto* backend = static_cast(context); MeshtasticHeader header; @@ -489,6 +500,16 @@ error_t setChannel(void* context, const MeshChannel* channel) { return ERROR_NONE; } +error_t removeChannel(void* context, uint8_t index) { + auto* backend = static_cast(context); + const size_t removed = std::erase_if(backend->channels, [index](const Channel& existing) { return existing.index == index; }); + if (removed == 0) { + return ERROR_NOT_FOUND; + } + saveChannels(backend); + return ERROR_NONE; +} + // endregion } // namespace @@ -505,4 +526,7 @@ const MeshBackendApi meshtastic_backend_api = { .send_text = sendText, .get_channels = getChannels, .set_channel = setChannel, + .is_valid_contact_address = isValidContactAddress, + .on_node_name_changed = onNodeNameChanged, + .remove_channel = removeChannel, }; diff --git a/Modules/mesh-module/README.md b/Modules/mesh-module/README.md index eb0ec7036..4479055ed 100644 --- a/Modules/mesh-module/README.md +++ b/Modules/mesh-module/README.md @@ -18,5 +18,4 @@ Mesh networking service (`tactility.mesh`) with pluggable protocol backends, suc - All backend calls run on the service thread. Apps get events with the same subscribe/poll pattern as the kernel's radio events. -The service only starts when enabled with `mesh_set_enabled()`. Firmware for devices without -`hardware.mesh=true` in `device.properties` doesn't link the mesh stack at all. +The service only starts when enabled with `mesh_set_enabled()`. diff --git a/Modules/mesh-module/include/mesh/backend.h b/Modules/mesh-module/include/mesh/backend.h index 037f74ab8..dbaa38f00 100644 --- a/Modules/mesh-module/include/mesh/backend.h +++ b/Modules/mesh-module/include/mesh/backend.h @@ -29,6 +29,8 @@ struct MeshRadioProfile { int32_t sync_word; int32_t preamble_length; int32_t power; + /** Radio channel for radios that are tuned by channel, e.g. a Wi-Fi channel for ESP-NOW. 0 when the frequency tunes the radio. */ + int32_t channel; }; struct MeshBackendApi { @@ -45,10 +47,11 @@ struct MeshBackendApi { const char* const* presets; /** - * @param[in] preset index in presets, 0 when the backend has none - * @retval ERROR_NOT_SUPPORTED when the backend can't operate in @a region with @a preset + * @param[in] modulation RADIO_MODULATION_LORA, or RADIO_MODULATION_ESPNOW for a radio that can't do LoRa + * @param[in] preset index in presets, 0 when the backend has none. Presets are for LoRa. + * @retval ERROR_NOT_SUPPORTED when the backend can't operate in @a region with @a modulation and @a preset */ - error_t (*get_radio_profile)(void* backend, enum RadioRegion region, size_t preset, struct MeshRadioProfile* profile); + error_t (*get_radio_profile)(void* backend, enum RadioRegion region, enum RadioModulation modulation, size_t preset, struct MeshRadioProfile* profile); /** Called once the radio is on with the backend's profile */ void (*on_start)(void* backend); @@ -71,6 +74,26 @@ struct MeshBackendApi { /** Optional */ error_t (*set_channel)(void* backend, const struct MeshChannel* channel); + + /** + * Optional. Write a URI that other apps turn into a contact, to show as a QR code. + * @param[in] contact the contact's address, or NULL for this node + * @param[in] name the name of the contact, or of this node + * @retval ERROR_BUFFER_OVERFLOW when the URI doesn't fit + */ + error_t (*get_contact_uri)(void* backend, const struct MeshAddress* contact, const char* name, char* uri, size_t uri_size); + + /** Optional. Whether @a address can be the address of a contact, e.g. a public key of the right length. */ + bool (*is_valid_contact_address)(void* backend, const struct MeshAddress* address); + + /** Optional. The name this node announces changed, so the backend can announce it again. */ + void (*on_node_name_changed)(void* backend); + + /** + * Optional. The service doesn't call it for channel 0. + * @retval ERROR_NOT_FOUND when there is no such channel + */ + error_t (*remove_channel)(void* backend, uint8_t index); }; /** diff --git a/Modules/mesh-module/include/mesh/mesh.h b/Modules/mesh-module/include/mesh/mesh.h index 506c6216f..d6933409f 100644 --- a/Modules/mesh-module/include/mesh/mesh.h +++ b/Modules/mesh-module/include/mesh/mesh.h @@ -21,7 +21,7 @@ extern "C" { */ /** Incremented when the API changes in a way that matters to apps */ -#define MESH_API_VERSION 1 +#define MESH_API_VERSION 2 #define MESH_BACKEND_ID_MAX_LENGTH 15 #define MESH_ADDRESS_MAX_LENGTH 32 @@ -108,6 +108,13 @@ struct MeshNode { float snr; }; +/** A node that the user saved for direct messages */ +struct MeshContact { + char backend[MESH_BACKEND_ID_MAX_LENGTH + 1]; + struct MeshAddress address; + char name[MESH_NAME_MAX_LENGTH + 1]; +}; + /** * A channel of the active backend. How the name and key combine is backend-specific: Meshtastic derives * the channel identity from both, while for MeshCore the name is only a local label. @@ -138,6 +145,42 @@ error_t mesh_get_node_name(char* name, size_t name_size); /** @return true when a started radio can be used for mesh networking */ bool mesh_is_available(void); +/** @return the number of started radios that can be used for mesh networking: LoRa radios, and ESP-NOW radios */ +size_t mesh_get_radio_count(void); + +/** + * @param[in] index the radio index, below mesh_get_radio_count() + * @param[out] name the name of the radio device + * @retval ERROR_NOT_FOUND for an invalid index + */ +error_t mesh_get_radio_name(size_t index, char* name, size_t name_size); + +enum MeshRadioKind { + MESH_RADIO_LORA, + MESH_RADIO_ESPNOW +}; + +/** + * @param[in] index the radio index, below mesh_get_radio_count() + * @param[out] kind what the radio does + * @retval ERROR_NOT_FOUND for an invalid index + */ +error_t mesh_get_radio_kind(size_t index, enum MeshRadioKind* kind); + +/** + * Select the radio to use. The choice is persisted by name. + * @param[in] name the name of a radio, or NULL or empty for the default radio: the first radio that isn't ESP-NOW, + * or the first ESP-NOW radio when there is no other + * @retval ERROR_NOT_FOUND when there is no such radio + */ +error_t mesh_set_radio(const char* name); + +/** + * Get the name of the radio that is used: the selected radio, or the default radio + * @retval ERROR_NOT_FOUND when there is no radio + */ +error_t mesh_get_radio(char* name, size_t name_size); + // endregion // region Backends @@ -212,6 +255,18 @@ error_t mesh_raw_send(const char* backend, const uint8_t* data, size_t length); /** @retval ERROR_NOT_FOUND when there is no such message */ error_t mesh_message_get(uint32_t seq, struct MeshMessage* message); +/** + * Read the messages of one conversation of the active backend, newest first. + * @param[in] peer the peer of a direct conversation, or NULL for the conversation of @a channel + * @param[in] channel the channel index, ignored when @a peer is set + * @param[in] before_seq only return messages older than this, or 0 for the newest + * @param[out] messages buffer for the messages + * @param[in] max_count capacity of @a messages + * @param[out] count the number of messages returned + * @retval ERROR_INVALID_STATE when the service is not running + */ +error_t mesh_conversation_get(const struct MeshAddress* peer, uint8_t channel, uint32_t before_seq, struct MeshMessage* messages, size_t max_count, size_t* count); + /** * Read stored messages, newest first. * @param[in] before_seq only return messages older than this, or 0 for the newest @@ -223,6 +278,40 @@ error_t mesh_history_get(uint32_t before_seq, struct MeshMessage* messages, size // endregion +// region Contacts + +/** + * Get the saved contacts of the active backend. A node that sends a direct message is saved automatically. + * @retval ERROR_INVALID_STATE when the service is not running + */ +error_t mesh_get_contacts(struct MeshContact* contacts, size_t max_count, size_t* count); + +/** + * Save a contact for the active backend, or rename it when the address is saved already. The choice is persisted. + * @param[in] name the name to show, or NULL to take it from the nodes that were heard + * @retval ERROR_INVALID_STATE when the service is not running + * @retval ERROR_INVALID_ARGUMENT when the active backend doesn't accept the address, e.g. a public key of the wrong length + */ +error_t mesh_add_contact(const struct MeshAddress* address, const char* name); + +/** + * Remove a contact of the active backend. The stored messages remain. + * @retval ERROR_NOT_FOUND when there is no such contact + */ +error_t mesh_remove_contact(const struct MeshAddress* address); + +/** + * Get a URI of a contact of the active backend that other mesh apps can scan as a QR code to add it. + * @param[in] contact the contact's address, or NULL for this node + * @param[out] uri the URI + * @retval ERROR_NOT_SUPPORTED when the backend has no such URI + * @retval ERROR_NOT_FOUND when @a contact isn't a saved contact + * @retval ERROR_BUFFER_OVERFLOW when the URI doesn't fit + */ +error_t mesh_get_contact_uri(const struct MeshAddress* contact, char* uri, size_t uri_size); + +// endregion + // region Nodes and channels /** Get the nodes heard by the active backend since it started */ @@ -238,6 +327,14 @@ error_t mesh_get_channels(struct MeshChannel* channels, size_t max_count, size_t */ error_t mesh_set_channel(const struct MeshChannel* channel); +/** + * Leave a channel. The stored messages remain. Channel 0 is the primary channel and can't be removed. + * @retval ERROR_NOT_FOUND when there is no such channel + * @retval ERROR_INVALID_ARGUMENT for channel 0 + * @retval ERROR_NOT_SUPPORTED when the backend has no channels + */ +error_t mesh_remove_channel(uint8_t index); + // endregion // region Events @@ -253,6 +350,10 @@ enum MeshEventType { MESH_EVENT_NODE, /** The active backend received a frame that isn't a message, see MeshEvent.frame. It isn't stored. */ MESH_EVENT_FRAME, + /** A contact was added or removed */ + MESH_EVENT_CONTACT, + /** A channel was added, replaced or removed */ + MESH_EVENT_CHANNEL, }; enum MeshFrameKind { diff --git a/Modules/mesh-module/private/mesh/mesh_settings.h b/Modules/mesh-module/private/mesh/mesh_settings.h index b70619aaf..a7991e598 100644 --- a/Modules/mesh-module/private/mesh/mesh_settings.h +++ b/Modules/mesh-module/private/mesh/mesh_settings.h @@ -11,6 +11,8 @@ struct MeshSettings { /** Empty for the backend's default preset */ char preset[MESH_NAME_MAX_LENGTH + 1]; char node_name[MESH_NAME_MAX_LENGTH + 1]; + /** The name of the radio device, empty for the default radio */ + char radio[MESH_NAME_MAX_LENGTH + 1]; }; /** Loads the settings, or defaults when there are none */ diff --git a/Modules/mesh-module/private/mesh/mesh_store.h b/Modules/mesh-module/private/mesh/mesh_store.h index 7b4852db4..1925a49de 100644 --- a/Modules/mesh-module/private/mesh/mesh_store.h +++ b/Modules/mesh-module/private/mesh/mesh_store.h @@ -33,6 +33,10 @@ private: uint32_t segment; uint32_t offset; enum MeshDeliveryState delivery; + /** Channel index, meaningful for messages without a peer */ + uint8_t channel; + /** Hash of the peer address, 0 for channel messages */ + uint32_t peerHash; }; static constexpr uint32_t PENDING = UINT32_MAX; @@ -50,6 +54,7 @@ private: tt::std::string getSegmentPath(uint32_t segment) const; /** @return the end offset of the last valid record */ uint32_t scanSegment(uint32_t segment); + static IndexEntry makeIndexEntry(const MeshMessage& message, uint32_t segment, uint32_t offset); void applyRecord(uint8_t type, const uint8_t* payload, size_t length, uint32_t segment, uint32_t offset); error_t appendRecord(uint8_t type, const uint8_t* payload, size_t length, uint32_t seq); IndexEntry* findEntry(uint32_t seq); @@ -80,6 +85,12 @@ public: /** Newest first, see mesh_history_get() */ size_t getHistory(uint32_t beforeSeq, MeshMessage* messages, size_t maxCount) const; + /** + * Newest first, like getHistory(), limited to the messages of one conversation of @a backend. + * @param[in] peer the peer of a direct conversation, or nullptr for the conversation of @a channel + */ + size_t getConversation(const char* backend, const MeshAddress* peer, uint8_t channel, uint32_t beforeSeq, MeshMessage* messages, size_t maxCount) const; + /** Sequence numbers of outgoing messages of @a backend in the given delivery state, oldest first */ tt::std::vector findOutgoing(const char* backend, enum MeshDeliveryState delivery) const; diff --git a/Modules/mesh-module/source/mesh_airtime.cpp b/Modules/mesh-module/source/mesh_airtime.cpp index dbb14e7ce..216f53521 100644 --- a/Modules/mesh-module/source/mesh_airtime.cpp +++ b/Modules/mesh-module/source/mesh_airtime.cpp @@ -17,6 +17,12 @@ uint32_t mesh_airtime_estimate_ms(const MeshRadioProfile& profile, size_t length return (uint32_t)std::ceil(preambleMs + payloadSymbols * symbolMs); } + if (profile.modulation == RADIO_MODULATION_ESPNOW) { + // Wi-Fi long range runs at 0.5 Mbit/s, and a frame has about 100 bytes of headers + constexpr double BIT_RATE = 500000.0; + return (uint32_t)std::ceil((100.0 + length) * 8.0 * 1000.0 / BIT_RATE); + } + // FSK-family: preamble, sync word, length byte, payload and CRC. The data rate is not part of the // profile, so a conservative 4.8 kbit/s is assumed. constexpr double FALLBACK_BIT_RATE = 4800.0; diff --git a/Modules/mesh-module/source/mesh_command.cpp b/Modules/mesh-module/source/mesh_command.cpp index cd6da30fd..0e8fece9e 100644 --- a/Modules/mesh-module/source/mesh_command.cpp +++ b/Modules/mesh-module/source/mesh_command.cpp @@ -2,16 +2,31 @@ #include #include +#include +#include #include +#include +#include +#include + +#include + #include #include #include +#include +#include #include +#include #include +#include constexpr size_t CHANNEL_LIMIT = 8; +constexpr size_t INSPECT_DEFAULT_AMOUNT = 20; +constexpr size_t INSPECT_MAX_AMOUNT = 200; +constexpr char CTRL_C = 0x03; static void print_usage() { printf( @@ -23,6 +38,7 @@ static void print_usage() { " preset []\n" " channels\n" " join <#channel>\n" + " inspect [amount] [-f|--follow]\n" ); } @@ -216,6 +232,227 @@ static int command_join(int argc, char* argv[]) { return 0; } +static void inspect_format_address(const MeshAddress& address, char* out, size_t size) { + // The first bytes identify the node well enough to tell nodes apart + out[0] = '\0'; + for (size_t i = 0; i < address.length && i < 4 && strlen(out) + 2 < size; i++) { + snprintf(out + strlen(out), size - strlen(out), "%02x", address.bytes[i]); + } +} + +static void inspect_format_signal(float rssi, float snr, char* out, size_t size) { + if (std::isnan(snr)) { + snprintf(out, size, "%.0f dBm", rssi); + } else { + snprintf(out, size, "%.0f dBm, %.1f dB", rssi, snr); + } +} + +static const char* inspect_delivery_text(MeshDeliveryState delivery) { + switch (delivery) { + case MESH_DELIVERY_QUEUED: return "queued"; + case MESH_DELIVERY_BLOCKED: return "blocked"; + case MESH_DELIVERY_SENT: return "sent"; + case MESH_DELIVERY_DELIVERED: return "delivered"; + case MESH_DELIVERY_FAILED: return "failed"; + case MESH_DELIVERY_CANCELLED: return "cancelled"; + default: return ""; + } +} + +static void inspect_load_channel_names(tt::std::map& names) { + MeshChannel channels[CHANNEL_LIMIT]; + size_t count = 0; + if (mesh_get_channels(channels, CHANNEL_LIMIT, &count) != ERROR_NONE) { + return; + } + names.clear(); + for (size_t i = 0; i < count; i++) { + names[channels[i].index] = channels[i]; + } +} + +static void inspect_print_message(const tt::std::map& channels, const MeshMessage& message) { + char target[MESH_NAME_MAX_LENGTH + 16]; + if (message.peer.length == 0) { + const auto channel = channels.find(message.channel); + if (channel != channels.end()) { + snprintf(target, sizeof(target), "%s", channel->second.name); + } else { + snprintf(target, sizeof(target), "channel %u", (unsigned)message.channel); + } + } else { + inspect_format_address(message.peer, target, sizeof(target)); + } + if (message.outgoing) { + printf("%s > %s [%s]: %s\n", message.backend, target, inspect_delivery_text(message.delivery), message.text); + } else { + char signal[40]; + inspect_format_signal(message.rssi, message.snr, signal, sizeof(signal)); + printf("%s < %s %s (%s): %s\n", message.backend, message.sender_name[0] != '\0' ? message.sender_name : "?", target, signal, message.text); + } + fflush(stdout); +} + +static void inspect_print_frame(const MeshEvent& event) { + char backend[MESH_BACKEND_ID_MAX_LENGTH + 1] = "-"; + mesh_get_backend(backend, sizeof(backend)); + char signal[40]; + inspect_format_signal(event.frame.rssi, event.frame.snr, signal, sizeof(signal)); + if (event.frame.kind == MESH_FRAME_ADVERT) { + char from[16]; + inspect_format_address(event.frame.from, from, sizeof(from)); + printf("%s < advert %s (%s): %s\n", backend, from, signal, event.frame.name[0] != '\0' ? event.frame.name : "[no name]"); + } else { + printf("%s < %u bytes (%s): [could not read]\n", backend, (unsigned)event.frame.length, signal); + } + fflush(stdout); +} + +static void inspect_print_message_by_seq(const tt::std::map& channels, uint32_t seq) { + auto* message = tt::newObject(); + if (message == nullptr) { + return; + } + if (mesh_message_get(seq, message) == ERROR_NONE) { + inspect_print_message(channels, *message); + } + tt::deleteObject(message); +} + +/** Prints the most recent messages, oldest first */ +/** @return the seq of the newest printed message, 0 when there is none */ +static uint32_t inspect_print_history(const tt::std::map& channels, size_t amount) { + tt::std::vector history(amount); + size_t count = 0; + if (mesh_history_get(0, history.data(), amount, &count) != ERROR_NONE) { + return 0; + } + for (size_t i = count; i > 0; i--) { + inspect_print_message(channels, history[i - 1]); + } + return count > 0 ? history[0].seq : 0; +} + +/** @return the key that asks to stop ('q' or Ctrl+C, which arrives as a byte on stdin), or 0 when none was typed */ +static char inspect_stop_key_typed() { + char typed = 0; + char byte = 0; + while (app_io_await(STDIN_FILENO, APP_FILE_WAIT_READABLE, 0) == ERROR_NONE && app_io_read(STDIN_FILENO, &byte, 1) == 1) { + if (byte == CTRL_C || byte == 'q' || byte == 'Q') { + typed = byte; + } + } + return typed; +} + +static int command_inspect(int argc, char* argv[]) { + size_t amount = INSPECT_DEFAULT_AMOUNT; + bool follow = false; + for (int i = 2; i < argc; i++) { + if (strcmp(argv[i], "-f") == 0 || strcmp(argv[i], "--follow") == 0) { + follow = true; + continue; + } + char* end = nullptr; + const unsigned long value = strtoul(argv[i], &end, 10); + if (argv[i][0] < '0' || argv[i][0] > '9' || end == argv[i] || *end != '\0' || value == 0) { + printf("usage: mesh inspect [amount] [-f|--follow]\n"); + return 1; + } + amount = std::min(value, INSPECT_MAX_AMOUNT); + } + + if (!follow) { + tt::std::map channels; + inspect_load_channel_names(channels); + inspect_print_history(channels, amount); + return 0; + } + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + AppEventSubscription appEvents {}; + MeshEventSubscription meshEvents {}; + if (app_event_subscribe(&appEvents, &eventGroup) != ERROR_NONE) { + task_event_group_destruct(&eventGroup); + return report_error("inspect", ERROR_RESOURCE); + } + // Subscribed before the history is read, so no message falls between the two + const error_t subscribed = mesh_event_subscribe(&meshEvents, &eventGroup); + if (subscribed != ERROR_NONE) { + app_event_unsubscribe(&appEvents); + task_event_group_destruct(&eventGroup); + return report_error("inspect", subscribed); + } + + printf("state: %s (Ctrl+C or q to stop)\n", mesh_state_to_string(mesh_get_state())); + fflush(stdout); + tt::std::map channels; + inspect_load_channel_names(channels); + uint32_t lastSeq = inspect_print_history(channels, amount); + const char* stopReason = nullptr; + bool stop = false; + while (!stop) { + // Wakes up on events, and regularly to look for Ctrl+C + uint32_t flags = 0; + task_event_group_wait_any(&eventGroup, &flags, pdMS_TO_TICKS(100)); + const char key = inspect_stop_key_typed(); + if (key != 0) { + stop = true; + stopReason = key == CTRL_C ? "Ctrl+C" : "q"; + } + + MeshEvent meshEvent {}; + while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { + switch (meshEvent.type) { + case MESH_EVENT_MESSAGE: + // The history may hold a message that also arrived as an event + if (meshEvent.seq > lastSeq) { + inspect_print_message_by_seq(channels, meshEvent.seq); + lastSeq = meshEvent.seq; + } + break; + case MESH_EVENT_FRAME: + inspect_print_frame(meshEvent); + break; + case MESH_EVENT_DELIVERY: + printf("#%u %s\n", (unsigned)meshEvent.seq, inspect_delivery_text(meshEvent.delivery)); + fflush(stdout); + break; + case MESH_EVENT_STATE: + inspect_load_channel_names(channels); + printf("state: %s\n", mesh_state_to_string(meshEvent.state)); + fflush(stdout); + break; + case MESH_EVENT_CHANNEL: + inspect_load_channel_names(channels); + break; + default: + break; + } + } + + AppEvent appEvent {}; + while (app_event_poll(&appEvents, &appEvent) == ERROR_NONE) { + // SIGTERM arrives as a close event, and Ctrl+C from a shell that sends signals as SIGINT + if (appEvent.type == APP_EVENT_CLOSE) { + stop = true; + stopReason = "closed"; + } else if (appEvent.type == APP_EVENT_SIGNAL && (appEvent.signal.sig == SIGINT || appEvent.signal.sig == SIGHUP)) { + stop = true; + stopReason = appEvent.signal.sig == SIGINT ? "SIGINT" : "SIGHUP"; + } + } + } + + printf("stopped: %s\n", stopReason); + mesh_event_unsubscribe(&meshEvents); + app_event_unsubscribe(&appEvents); + task_event_group_destruct(&eventGroup); + return 0; +} + static int32_t mesh_command_main(int argc, char* argv[]) { if (argc < 2 || strcmp(argv[1], "help") == 0) { print_usage(); @@ -237,6 +474,8 @@ static int32_t mesh_command_main(int argc, char* argv[]) { return command_channels(); } else if (strcmp(command, "join") == 0) { return command_join(argc, argv); + } else if (strcmp(command, "inspect") == 0) { + return command_inspect(argc, argv); } printf("mesh: unknown command: %s\n", command); @@ -252,7 +491,7 @@ const AppManifest mesh_command_manifest = { .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(mesh_command_main) }, .flags = APP_MANIFEST_FLAG_HEADLESS, - .stack = { .depth = 5120, .desired_memory_capability = 0 }, + .stack = { .depth = 6144, .desired_memory_capability = 0 }, }; } diff --git a/Modules/mesh-module/source/mesh_service.cpp b/Modules/mesh-module/source/mesh_service.cpp index 61dd96a47..37ca9c836 100644 --- a/Modules/mesh-module/source/mesh_service.cpp +++ b/Modules/mesh-module/source/mesh_service.cpp @@ -92,20 +92,73 @@ const MeshBackendApi* findBackend(const char* id) { return nullptr; } -/** Takes a reference on the first started radio that can send and receive LoRa. @return nullptr when there is none */ +void ensureSettingsLoaded(); + +bool canSendAndReceive(Device* device, RadioModulation modulation) { + return radio_can_transmit(device, modulation) && radio_can_receive(device, modulation); +} + +/** Whether the radio does LoRa. Other mesh radios do ESP-NOW. */ +bool usesLora(Device* device) { + return canSendAndReceive(device, RADIO_MODULATION_LORA); +} + +/** A started radio that can be used for mesh networking */ +bool isMeshRadio(Device* device) { + return device_is_ready(device) && (usesLora(device) || canSendAndReceive(device, RADIO_MODULATION_ESPNOW)); +} + +/** + * Takes a reference on the selected radio. Without a selection, or when the selected radio isn't there, + * that is the first radio that isn't ESP-NOW, or the first ESP-NOW radio when there is no other. + * @return nullptr when there is none + */ Device* findRadio() { - Device* found = nullptr; - device_for_each_of_type(&RADIO_TYPE, &found, [](Device* device, void* context) { - if (!device_is_ready(device) || !radio_can_transmit(device, RADIO_MODULATION_LORA) || !radio_can_receive(device, RADIO_MODULATION_LORA)) { + char name[MESH_NAME_MAX_LENGTH + 1]; + lock(); + ensureSettingsLoaded(); + strncpy(name, globals().settings.radio, sizeof(name)); + name[MESH_NAME_MAX_LENGTH] = '\0'; + unlock(); + + struct Search { + const char* name; + Device* selected = nullptr; + Device* lora = nullptr; + Device* other = nullptr; + } search { name }; + device_for_each_of_type(&RADIO_TYPE, &search, [](Device* device, void* context) { + auto* search = static_cast(context); + if (!isMeshRadio(device)) { return true; } - if (device_get(device) != ERROR_NONE) { - return true; + if (search->name[0] != '\0' && strcmp(device->name, search->name) == 0) { + search->selected = device; + return false; } - *static_cast(context) = device; - return false; + Device*& slot = usesLora(device) ? search->lora : search->other; + if (slot == nullptr) { + slot = device; + } + return true; }); - return found; + + Device* radio = search.selected != nullptr ? search.selected : (search.lora != nullptr ? search.lora : search.other); + if (radio != nullptr && device_get(radio) != ERROR_NONE) { + return nullptr; + } + return radio; +} + +/** Whether the presets of the backend apply to the radio that is used: they are for LoRa */ +bool presetsApply() { + Device* radio = findRadio(); + if (radio == nullptr) { + return false; + } + const bool applies = usesLora(radio); + device_put(radio); + return applies; } /** Requires the global lock and loaded settings */ @@ -210,6 +263,8 @@ struct MeshHost { MeshState state = MESH_STATE_OFF; MeshStore store; tt::std::vector nodes; + /** The saved contacts of every backend */ + tt::std::vector contacts; char activeBackend[MESH_BACKEND_ID_MAX_LENGTH + 1] = {}; tt::std::deque> calls; @@ -261,6 +316,120 @@ void setStateLocked(MeshHost* host, MeshState state) { unlock(); } +// region Contacts + +constexpr size_t MAX_CONTACTS = 64; +constexpr auto* CONTACTS_FILE_NAME = "contacts.bin"; + +bool isSameAddress(const MeshAddress& a, const MeshAddress& b) { + return a.length == b.length && memcmp(a.bytes, b.bytes, a.length) == 0; +} + +/** Requires the global lock */ +MeshContact* findContact(MeshHost* host, const MeshAddress& address) { + for (auto& contact : host->contacts) { + if (strncmp(contact.backend, host->activeBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0 && isSameAddress(contact.address, address)) { + return &contact; + } + } + return nullptr; +} + +void loadContacts(MeshHost* host) { + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (service_paths_get_user_data_path(MESH_SERVICE_ID, CONTACTS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { + return; + } + FILE* file = fopen(path, "rb"); + if (file == nullptr) { + return; + } + MeshContact contact; + while (host->contacts.size() < MAX_CONTACTS && fread(&contact, sizeof(contact), 1, file) == 1) { + contact.backend[MESH_BACKEND_ID_MAX_LENGTH] = '\0'; + contact.name[MESH_NAME_MAX_LENGTH] = '\0'; + if (contact.address.length > 0 && contact.address.length <= MESH_ADDRESS_MAX_LENGTH) { + host->contacts.push_back(contact); + } + } + fclose(file); +} + +/** Requires the global lock */ +void saveContacts(MeshHost* host) { + char directory[FILE_MAX_PATH_STRING_LENGTH]; + char path[FILE_MAX_PATH_STRING_LENGTH]; + if (service_paths_get_user_data_directory(MESH_SERVICE_ID, directory, sizeof(directory)) != ERROR_NONE || + service_paths_get_user_data_path(MESH_SERVICE_ID, CONTACTS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { + return; + } + const error_t made = directory_make(directory, true); + if (made != ERROR_NONE && made != ERROR_ALREADY_EXISTS) { + LOG_E(TAG, "Failed to create %s", directory); + return; + } + FILE* file = fopen(path, "wb"); + if (file == nullptr) { + LOG_E(TAG, "Failed to save the contacts"); + return; + } + if (!host->contacts.empty() && fwrite(host->contacts.data(), sizeof(MeshContact), host->contacts.size(), file) != host->contacts.size()) { + LOG_E(TAG, "Failed to save the contacts"); + } + fclose(file); +} + +/** + * Save a contact of the active backend. An existing contact is renamed when @a rename is set. + * Requires the global lock. + */ +error_t addContact(MeshHost* host, const MeshAddress& address, const char* name, bool rename) { + if (address.length == 0 || address.length > MESH_ADDRESS_MAX_LENGTH) { + return ERROR_INVALID_ARGUMENT; + } + + char resolvedName[MESH_NAME_MAX_LENGTH + 1] = {}; + if (name != nullptr) { + strncpy(resolvedName, name, MESH_NAME_MAX_LENGTH); + } + if (resolvedName[0] == '\0') { + for (const auto& node : host->nodes) { + if (isSameAddress(node.address, address) && strncmp(node.backend, host->activeBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0) { + strncpy(resolvedName, node.name, MESH_NAME_MAX_LENGTH); + break; + } + } + } + if (resolvedName[0] == '\0') { + // The first bytes identify the node well enough to tell nodes apart + for (size_t i = 0; i < address.length && i < 4; i++) { + snprintf(resolvedName + i * 2, 3, "%02x", address.bytes[i]); + } + } + + auto* existing = findContact(host, address); + if (existing != nullptr) { + if (!rename || name == nullptr || name[0] == '\0') { + return ERROR_NONE; + } + strncpy(existing->name, resolvedName, MESH_NAME_MAX_LENGTH); + } else { + if (host->contacts.size() >= MAX_CONTACTS) { + return ERROR_RESOURCE; + } + MeshContact contact {}; + strncpy(contact.backend, host->activeBackend, MESH_BACKEND_ID_MAX_LENGTH); + contact.address = address; + strncpy(contact.name, resolvedName, MESH_NAME_MAX_LENGTH); + host->contacts.push_back(contact); + } + saveContacts(host); + emitEvent(MESH_EVENT_CONTACT, host->state, 0, MESH_DELIVERY_RECEIVED); + return ERROR_NONE; +} + +// endregion + /** Requires the global lock */ void setDelivery(MeshHost* host, uint32_t seq, MeshDeliveryState delivery) { if (host->store.setDelivery(seq, delivery) == ERROR_NONE) { @@ -300,6 +469,33 @@ error_t callOnServiceThread(const std::function& work) { // region Radio and backend lifecycle (service thread) +/** Subscribes to the events of @a radio, which is a referenced device. */ +void attachRadio(MeshHost* host, Device* radio) { + host->radio = radio; + host->rxSubscribed = radio_rx_event_subscribe(&host->rxSub, &host->group, host->radio) == ERROR_NONE; + host->txSubscribed = radio_tx_event_subscribe(&host->txSub, &host->group, host->radio) == ERROR_NONE; +} + +/** Unsubscribes from the radio, releases it and drops the reference */ +void detachRadio(MeshHost* host) { + if (host->rxSubscribed) { + radio_rx_event_unsubscribe(&host->rxSub); + host->rxSubscribed = false; + } + if (host->txSubscribed) { + radio_tx_event_unsubscribe(&host->txSub); + host->txSubscribed = false; + } + if (host->radio != nullptr) { + if (host->radioAcquired) { + radio_release(host->radio); + host->radioAcquired = false; + } + device_put(host->radio); + host->radio = nullptr; + } +} + void radioOff(MeshHost* host) { if (host->radio != nullptr && host->radioAcquired) { radio_set_enabled(host->radio, false); @@ -356,6 +552,13 @@ bool applyProfile(MeshHost* host) { return false; } } + if (profile.channel != 0) { + const error_t result = radio_set_parameter(host->radio, RADIO_PARAMETER_CHANNEL, profile.channel); + if (result != ERROR_NONE) { + LOG_E(TAG, "Setting the radio channel to %d failed: %d", (int)profile.channel, (int)result); + return false; + } + } // Listen-before-talk is optional, so a driver without it is fine radio_set_parameter(host->radio, RADIO_PARAMETER_LBT, 1); @@ -393,8 +596,9 @@ void configure(MeshHost* host) { return; } + // Only LoRa is bound to a region. The Wi-Fi stack limits ESP-NOW by country. host->appliedRegion = radio_get_region(host->radio); - if (host->appliedRegion == RADIO_REGION_UNSET) { + if (host->appliedRegion == RADIO_REGION_UNSET && usesLora(host->radio)) { setStateLocked(host, MESH_STATE_NO_REGION); return; } @@ -407,7 +611,9 @@ void configure(MeshHost* host) { setStateLocked(host, MESH_STATE_ERROR); return; } - if (api->get_radio_profile(backend, host->appliedRegion, preset, &host->profile) != ERROR_NONE) { + // A radio that doesn't do LoRa does ESP-NOW + const RadioModulation modulation = usesLora(host->radio) ? RADIO_MODULATION_LORA : RADIO_MODULATION_ESPNOW; + if (api->get_radio_profile(backend, host->appliedRegion, modulation, preset, &host->profile) != ERROR_NONE) { LOG_E(TAG, "Backend %s doesn't support region %s with preset %u", api->id, radio_region_to_string(host->appliedRegion), (unsigned)preset); api->destroy(backend); host->api = nullptr; @@ -490,7 +696,8 @@ void pumpTx(MeshHost* host) { const auto& frame = host->txQueue.front(); const uint32_t airtimeMs = mesh_airtime_estimate_ms(host->profile, frame.data.size()); - const uint32_t dutyCycle = mesh_airtime_duty_cycle_percent(host->appliedRegion, host->profile.frequency); + // Wi-Fi radios have no duty cycle limit + const uint32_t dutyCycle = (host->profile.modulation == RADIO_MODULATION_ESPNOW) ? 100 : mesh_airtime_duty_cycle_percent(host->appliedRegion, host->profile.frequency); const uint64_t nowMs = getMillis(); if (dutyCycle < 100) { const uint64_t budgetMs = DUTY_CYCLE_WINDOW_MS * dutyCycle / 100; @@ -562,17 +769,27 @@ void onTxEvent(MeshHost* host, const RadioTxEvent& event) { // region Service thread -/** Takes the first LoRa radio and subscribes to its events. @return false when there is none */ +/** Takes the first radio and subscribes to its events. @return false when there is none */ bool attachRadio(MeshHost* host) { - host->radio = findRadio(); - if (host->radio == nullptr) { + Device* radio = findRadio(); + if (radio == nullptr) { return false; } - host->rxSubscribed = radio_rx_event_subscribe(&host->rxSub, &host->group, host->radio) == ERROR_NONE; - host->txSubscribed = radio_tx_event_subscribe(&host->txSub, &host->group, host->radio) == ERROR_NONE; + attachRadio(host, radio); return true; } +/** Moves to the radio that is selected in the settings */ +void reselectRadio(MeshHost* host) { + deactivateBackend(host); + detachRadio(host); + if (!attachRadio(host)) { + setStateLocked(host, MESH_STATE_NO_RADIO); + return; + } + configure(host); +} + void drainRadioEvents(MeshHost* host) { if (host->rxSubscribed) { // A host member: RadioRxEvent is large and only used on this thread @@ -612,7 +829,7 @@ void runCalls(MeshHost* host) { void tick(MeshHost* host) { // Radios can start after the service, e.g. once the hotplug poller detects an add-on if (host->radio == nullptr && attachRadio(host)) { - LOG_I(TAG, "LoRa radio found"); + LOG_I(TAG, "Mesh radio found"); configure(host); } if (host->radio != nullptr && host->radioAcquired && radio_get_region(host->radio) != host->appliedRegion) { @@ -673,22 +890,7 @@ void destroyService(const ServiceManifest*, void* data) { } void releaseResources(MeshHost* host) { - if (host->rxSubscribed) { - radio_rx_event_unsubscribe(&host->rxSub); - host->rxSubscribed = false; - } - if (host->txSubscribed) { - radio_tx_event_unsubscribe(&host->txSub); - host->txSubscribed = false; - } - if (host->radio != nullptr) { - if (host->radioAcquired) { - radio_release(host->radio); - host->radioAcquired = false; - } - device_put(host->radio); - host->radio = nullptr; - } + detachRadio(host); task_event_group_destruct(&host->group); event_group_destruct(&host->callEvents); } @@ -711,9 +913,15 @@ error_t onStart(ServiceInstance*, void* data) { releaseResources(host); return ERROR_RESOURCE; } + loadContacts(host); if (!attachRadio(host)) { - LOG_W(TAG, "No LoRa radio found yet"); + LOG_W(TAG, "No mesh radio found yet"); + device_for_each_of_type(&RADIO_TYPE, nullptr, [](Device* device, void*) { + LOG_I(TAG, "Radio %s: ready=%d lora=%d espnow=%d", device->name, (int)device_is_ready(device), + (int)usesLora(device), (int)canSendAndReceive(device, RADIO_MODULATION_ESPNOW)); + return true; + }); } lock(); @@ -883,6 +1091,10 @@ uint32_t mesh_host_receive_text( uint32_t seq = 0; if (host->store.add(&message) == ERROR_NONE) { seq = message.seq; + // A sender of a direct message is saved, so the conversation can be found and answered + if (from != nullptr && from->length > 0) { + addContact(host, *from, sender_name, false); + } emitEvent(MESH_EVENT_MESSAGE, host->state, seq, MESH_DELIVERY_RECEIVED); } unlock(); @@ -1022,6 +1234,14 @@ error_t mesh_set_node_name(const char* name) { strncpy(globals().settings.node_name, name, MESH_NAME_MAX_LENGTH); globals().settings.node_name[MESH_NAME_MAX_LENGTH] = '\0'; const MeshSettings copy = globals().settings; + // The backend announces the new name + if (service != nullptr) { + post(service, [host = service] { + if (host->api != nullptr && host->api->on_node_name_changed != nullptr) { + host->api->on_node_name_changed(host->backend); + } + }); + } unlock(); return mesh_settings_save(©); } @@ -1043,6 +1263,103 @@ bool mesh_is_available(void) { return true; } +size_t mesh_get_radio_count(void) { + size_t count = 0; + device_for_each_of_type(&RADIO_TYPE, &count, [](Device* device, void* context) { + if (isMeshRadio(device)) { + (*static_cast(context))++; + } + return true; + }); + return count; +} + +error_t mesh_get_radio_kind(size_t index, MeshRadioKind* kind) { + struct Search { + size_t index; + MeshRadioKind* kind; + error_t result; + } search = { index, kind, ERROR_NOT_FOUND }; + device_for_each_of_type(&RADIO_TYPE, &search, [](Device* device, void* context) { + auto* search = static_cast(context); + if (!isMeshRadio(device)) { + return true; + } + if (search->index-- > 0) { + return true; + } + *search->kind = usesLora(device) ? MESH_RADIO_LORA : MESH_RADIO_ESPNOW; + search->result = ERROR_NONE; + return false; + }); + return search.result; +} + +error_t mesh_get_radio_name(size_t index, char* name, size_t name_size) { + struct Search { + size_t index; + char* name; + size_t size; + error_t result; + } search = { index, name, name_size, ERROR_NOT_FOUND }; + device_for_each_of_type(&RADIO_TYPE, &search, [](Device* device, void* context) { + auto* search = static_cast(context); + if (!isMeshRadio(device)) { + return true; + } + if (search->index-- > 0) { + return true; + } + if (search->size > 0) { + strncpy(search->name, device->name, search->size - 1); + search->name[search->size - 1] = '\0'; + search->result = ERROR_NONE; + } + return false; + }); + return search.result; +} + +error_t mesh_get_radio(char* name, size_t name_size) { + Device* radio = findRadio(); + if (radio == nullptr || name_size == 0) { + if (radio != nullptr) { + device_put(radio); + } + return ERROR_NOT_FOUND; + } + strncpy(name, radio->name, name_size - 1); + name[name_size - 1] = '\0'; + device_put(radio); + return ERROR_NONE; +} + +error_t mesh_set_radio(const char* name) { + const char* selected = (name != nullptr) ? name : ""; + if (selected[0] != '\0') { + bool found = false; + for (size_t i = 0, count = mesh_get_radio_count(); i < count && !found; i++) { + char radioName[MESH_NAME_MAX_LENGTH + 1]; + found = mesh_get_radio_name(i, radioName, sizeof(radioName)) == ERROR_NONE && strcmp(radioName, selected) == 0; + } + if (!found) { + return ERROR_NOT_FOUND; + } + } + + lock(); + ensureSettingsLoaded(); + strncpy(globals().settings.radio, selected, MESH_NAME_MAX_LENGTH); + globals().settings.radio[MESH_NAME_MAX_LENGTH] = '\0'; + const MeshSettings copy = globals().settings; + if (service != nullptr) { + MeshHost* host = service; + post(host, [host] { reselectRadio(host); }); + } + unlock(); + return mesh_settings_save(©); +} + size_t mesh_get_backend_count(void) { lock(); const size_t count = globals().backendCount; @@ -1101,20 +1418,22 @@ error_t mesh_get_backend(char* id, size_t id_size) { } size_t mesh_get_preset_count(void) { + const bool applies = presetsApply(); lock(); ensureSettingsLoaded(); const MeshBackendApi* api = getSelectedBackend(); - const size_t count = (api != nullptr) ? getPresetCount(api) : 0; + const size_t count = (api != nullptr && applies) ? getPresetCount(api) : 0; unlock(); return count; } error_t mesh_get_preset_name(size_t index, char* name, size_t name_size) { + const bool applies = presetsApply(); lock(); ensureSettingsLoaded(); const MeshBackendApi* api = getSelectedBackend(); error_t result = ERROR_NOT_FOUND; - if (api != nullptr && index < getPresetCount(api) && name_size > 0) { + if (api != nullptr && applies && index < getPresetCount(api) && name_size > 0) { strncpy(name, api->presets[index], name_size - 1); name[name_size - 1] = '\0'; result = ERROR_NONE; @@ -1124,11 +1443,12 @@ error_t mesh_get_preset_name(size_t index, char* name, size_t name_size) { } error_t mesh_set_preset(const char* name) { + const bool applies = presetsApply(); lock(); ensureSettingsLoaded(); const MeshBackendApi* api = getSelectedBackend(); const size_t index = (api != nullptr) ? findPreset(api, name) : 0; - if (api == nullptr || index >= getPresetCount(api) || strcmp(api->presets[index], name) != 0) { + if (api == nullptr || !applies || index >= getPresetCount(api) || strcmp(api->presets[index], name) != 0) { unlock(); return ERROR_NOT_FOUND; } @@ -1144,11 +1464,12 @@ error_t mesh_set_preset(const char* name) { } error_t mesh_get_preset(char* name, size_t name_size) { + const bool applies = presetsApply(); lock(); ensureSettingsLoaded(); const MeshBackendApi* api = getSelectedBackend(); error_t result = ERROR_NOT_FOUND; - if (api != nullptr && getPresetCount(api) > 0 && name_size > 0) { + if (api != nullptr && applies && getPresetCount(api) > 0 && name_size > 0) { strncpy(name, api->presets[findPreset(api, globals().settings.preset)], name_size - 1); name[name_size - 1] = '\0'; result = ERROR_NONE; @@ -1254,6 +1575,108 @@ error_t mesh_history_get(uint32_t before_seq, MeshMessage* messages, size_t max_ return result; } +error_t mesh_conversation_get(const MeshAddress* peer, uint8_t channel, uint32_t before_seq, MeshMessage* messages, size_t max_count, size_t* count) { + lock(); + error_t result = ERROR_INVALID_STATE; + if (service != nullptr) { + *count = service->store.getConversation(service->activeBackend, peer, channel, before_seq, messages, max_count); + result = ERROR_NONE; + } + unlock(); + return result; +} + +error_t mesh_get_contacts(MeshContact* contacts, size_t max_count, size_t* count) { + lock(); + error_t result = ERROR_INVALID_STATE; + if (service != nullptr) { + *count = 0; + for (const auto& contact : service->contacts) { + if (*count < max_count && strncmp(contact.backend, service->activeBackend, MESH_BACKEND_ID_MAX_LENGTH) == 0) { + contacts[(*count)++] = contact; + } + } + result = ERROR_NONE; + } + unlock(); + return result; +} + +error_t mesh_add_contact(const MeshAddress* address, const char* name) { + if (address->length == 0 || address->length > MESH_ADDRESS_MAX_LENGTH) { + return ERROR_INVALID_ARGUMENT; + } + bool valid = true; + const error_t callResult = callOnServiceThread([&](MeshHost* host) { + if (host->api != nullptr && host->api->is_valid_contact_address != nullptr) { + valid = host->api->is_valid_contact_address(host->backend, address); + } + }); + if (callResult != ERROR_NONE) { + return callResult; + } + if (!valid) { + return ERROR_INVALID_ARGUMENT; + } + + lock(); + error_t result = ERROR_INVALID_STATE; + if (service != nullptr) { + result = addContact(service, *address, name, true); + } + unlock(); + return result; +} + +error_t mesh_remove_contact(const MeshAddress* address) { + lock(); + error_t result = ERROR_INVALID_STATE; + if (service != nullptr) { + auto* contact = findContact(service, *address); + if (contact == nullptr) { + result = ERROR_NOT_FOUND; + } else { + service->contacts.erase(service->contacts.begin() + (contact - service->contacts.data())); + saveContacts(service); + emitEvent(MESH_EVENT_CONTACT, service->state, 0, MESH_DELIVERY_RECEIVED); + result = ERROR_NONE; + } + } + unlock(); + return result; +} + +error_t mesh_get_contact_uri(const MeshAddress* contact, char* uri, size_t uri_size) { + char name[MESH_NAME_MAX_LENGTH + 1] = {}; + lock(); + if (service == nullptr) { + unlock(); + return ERROR_INVALID_STATE; + } + if (contact == nullptr) { + strncpy(name, globals().settings.node_name, MESH_NAME_MAX_LENGTH); + } else { + const auto* saved = findContact(service, *contact); + if (saved == nullptr) { + unlock(); + return ERROR_NOT_FOUND; + } + strncpy(name, saved->name, MESH_NAME_MAX_LENGTH); + } + unlock(); + + error_t result = ERROR_INVALID_STATE; + const error_t callResult = callOnServiceThread([&](MeshHost* host) { + if (host->api == nullptr) { + return; + } + result = (host->api->get_contact_uri != nullptr) + ? host->api->get_contact_uri(host->backend, contact, name, uri, uri_size) + : ERROR_NOT_SUPPORTED; + }); + return (callResult != ERROR_NONE) ? callResult : result; +} + error_t mesh_get_nodes(MeshNode* nodes, size_t max_count, size_t* count) { lock(); error_t result = ERROR_INVALID_STATE; @@ -1279,6 +1702,18 @@ error_t mesh_get_channels(MeshChannel* channels, size_t max_count, size_t* count return (callResult != ERROR_NONE) ? callResult : result; } +/** Tells the subscribers that the channels changed, when the change succeeded */ +static void emitChannelChanged(error_t result) { + if (result != ERROR_NONE) { + return; + } + lock(); + if (service != nullptr) { + emitEvent(MESH_EVENT_CHANNEL, service->state, 0, MESH_DELIVERY_RECEIVED); + } + unlock(); +} + error_t mesh_set_channel(const MeshChannel* channel) { error_t result = ERROR_INVALID_STATE; const error_t callResult = callOnServiceThread([&](MeshHost* host) { @@ -1289,7 +1724,27 @@ error_t mesh_set_channel(const MeshChannel* channel) { ? host->api->set_channel(host->backend, channel) : ERROR_NOT_SUPPORTED; }); - return (callResult != ERROR_NONE) ? callResult : result; + const error_t finalResult = (callResult != ERROR_NONE) ? callResult : result; + emitChannelChanged(finalResult); + return finalResult; +} + +error_t mesh_remove_channel(uint8_t index) { + if (index == 0) { + return ERROR_INVALID_ARGUMENT; + } + error_t result = ERROR_INVALID_STATE; + const error_t callResult = callOnServiceThread([&](MeshHost* host) { + if (host->api == nullptr) { + return; + } + result = (host->api->remove_channel != nullptr) + ? host->api->remove_channel(host->backend, index) + : ERROR_NOT_SUPPORTED; + }); + const error_t finalResult = (callResult != ERROR_NONE) ? callResult : result; + emitChannelChanged(finalResult); + return finalResult; } // endregion diff --git a/Modules/mesh-module/source/mesh_settings.cpp b/Modules/mesh-module/source/mesh_settings.cpp index 609bc1d63..f13690ab9 100644 --- a/Modules/mesh-module/source/mesh_settings.cpp +++ b/Modules/mesh-module/source/mesh_settings.cpp @@ -11,18 +11,32 @@ #include #include +#ifdef ESP_PLATFORM +#include +#else +#include +#endif + constexpr auto* TAG = "mesh_settings"; constexpr auto* SETTINGS_FILE_NAME = "settings.properties"; constexpr auto* KEY_ENABLED = "enabled"; constexpr auto* KEY_BACKEND = "backend"; constexpr auto* KEY_PRESET = "preset"; constexpr auto* KEY_NODE_NAME = "nodeName"; -constexpr auto* DEFAULT_NODE_NAME = "Tactility"; +constexpr auto* KEY_RADIO = "radio"; +// Earlier versions stored this as the name when the user hadn't chosen one +constexpr auto* LEGACY_DEFAULT_NODE_NAME = "Tactility"; -void mesh_settings_load(MeshSettings* settings) { - *settings = {}; - strncpy(settings->node_name, DEFAULT_NODE_NAME, MESH_NAME_MAX_LENGTH); +static uint32_t random_u32() { +#ifdef ESP_PLATFORM + return esp_random(); +#else + std::random_device device; + return device(); +#endif +} +static void read_settings_file(MeshSettings* settings) { char path[FILE_MAX_PATH_STRING_LENGTH]; if (service_paths_get_user_data_path(MESH_SERVICE_ID, SETTINGS_FILE_NAME, path, sizeof(path)) != ERROR_NONE) { return; @@ -46,9 +60,25 @@ void mesh_settings_load(MeshSettings* settings) { if (properties_file_get(file, KEY_NODE_NAME, value, sizeof(value)) == ERROR_NONE && value[0] != '\0') { strncpy(settings->node_name, value, MESH_NAME_MAX_LENGTH); } + if (properties_file_get(file, KEY_RADIO, value, sizeof(value)) == ERROR_NONE) { + strncpy(settings->radio, value, MESH_NAME_MAX_LENGTH); + } properties_file_close(file); } +void mesh_settings_load(MeshSettings* settings) { + *settings = {}; + read_settings_file(settings); + + // A name that the user didn't choose is made up once and then kept + if (settings->node_name[0] == '\0' || strcmp(settings->node_name, LEGACY_DEFAULT_NODE_NAME) == 0) { + snprintf(settings->node_name, sizeof(settings->node_name), "TactilityUser-%08x", (unsigned)random_u32()); + if (mesh_settings_save(settings) != ERROR_NONE) { + LOG_E(TAG, "Failed to save the generated name"); + } + } +} + error_t mesh_settings_save(const MeshSettings* settings) { char directory[FILE_MAX_PATH_STRING_LENGTH]; char path[FILE_MAX_PATH_STRING_LENGTH]; @@ -70,5 +100,6 @@ error_t mesh_settings_save(const MeshSettings* settings) { properties_file_set(file, KEY_BACKEND, settings->backend); properties_file_set(file, KEY_PRESET, settings->preset); properties_file_set(file, KEY_NODE_NAME, settings->node_name); + properties_file_set(file, KEY_RADIO, settings->radio); return properties_file_close(file); } diff --git a/Modules/mesh-module/source/mesh_store.cpp b/Modules/mesh-module/source/mesh_store.cpp index 3daa92d88..675fd7434 100644 --- a/Modules/mesh-module/source/mesh_store.cpp +++ b/Modules/mesh-module/source/mesh_store.cpp @@ -29,6 +29,22 @@ constexpr size_t MESSAGE_PAYLOAD_MAX_SIZE = 512; constexpr auto* SEGMENT_PREFIX = "seg-"; constexpr auto* SEGMENT_SUFFIX = ".log"; +bool isSameAddress(const MeshAddress& a, const MeshAddress& b) { + return a.length == b.length && memcmp(a.bytes, b.bytes, a.length) == 0; +} + +/** FNV-1a, never 0 for an address so that 0 can mean "no peer" */ +uint32_t hashPeer(const MeshAddress& address) { + if (address.length == 0) { + return 0; + } + uint32_t hash = 2166136261u; + for (size_t i = 0; i < address.length; i++) { + hash = (hash ^ address.bytes[i]) * 16777619u; + } + return hash | 1; +} + uint8_t* allocateBuffer(size_t size) { constexpr MemoryPolicy policy = { .required = 0, .desired = MEMORY_CAPABILITY_EXTERNAL, .alignment = 0 }; return static_cast(memory_alloc_with_policy(size, &policy)); @@ -277,11 +293,22 @@ uint32_t MeshStore::scanSegment(uint32_t segment) { return offset; } +MeshStore::IndexEntry MeshStore::makeIndexEntry(const MeshMessage& message, uint32_t segment, uint32_t offset) { + return { + .seq = message.seq, + .segment = segment, + .offset = offset, + .delivery = message.delivery, + .channel = message.channel, + .peerHash = hashPeer(message.peer), + }; +} + void MeshStore::applyRecord(uint8_t type, const uint8_t* payload, size_t length, uint32_t segment, uint32_t offset) { if (type == RECORD_TYPE_MESSAGE) { MeshMessage message; if (deserializeMessage(payload, length, &message)) { - index.push_back({ .seq = message.seq, .segment = segment, .offset = offset, .delivery = message.delivery }); + index.push_back(makeIndexEntry(message, segment, offset)); nextSeq = std::max(nextSeq, message.seq + 1); } } else if (type == RECORD_TYPE_DELIVERY && length >= 5) { @@ -318,7 +345,7 @@ error_t MeshStore::appendRecord(uint8_t type, const uint8_t* payload, size_t len if (type == RECORD_TYPE_MESSAGE) { MeshMessage message; deserializeMessage(payload, length, &message); - index.push_back({ .seq = seq, .segment = PENDING, .offset = (uint32_t)bufferUsed, .delivery = message.delivery }); + index.push_back(makeIndexEntry(message, PENDING, (uint32_t)bufferUsed)); } if (bufferUsed == 0) { @@ -400,6 +427,29 @@ error_t MeshStore::get(uint32_t seq, MeshMessage* message) const { return readEntry(*entry, message); } +size_t MeshStore::getConversation(const char* backend, const MeshAddress* peer, uint8_t channel, uint32_t beforeSeq, MeshMessage* messages, size_t maxCount) const { + const uint32_t peerHash = (peer != nullptr) ? hashPeer(*peer) : 0; + size_t count = 0; + for (auto iterator = index.rbegin(); iterator != index.rend() && count < maxCount; ++iterator) { + if (beforeSeq != 0 && iterator->seq >= beforeSeq) { + continue; + } + if (iterator->peerHash != peerHash || (peer == nullptr && iterator->channel != channel)) { + continue; + } + MeshMessage* message = &messages[count]; + // The hash can collide, so the stored message decides + if (readEntry(*iterator, message) != ERROR_NONE || strncmp(message->backend, backend, MESH_BACKEND_ID_MAX_LENGTH) != 0) { + continue; + } + if (peer != nullptr ? !isSameAddress(message->peer, *peer) : message->peer.length != 0) { + continue; + } + count++; + } + return count; +} + size_t MeshStore::getHistory(uint32_t beforeSeq, MeshMessage* messages, size_t maxCount) const { size_t count = 0; for (auto iterator = index.rbegin(); iterator != index.rend() && count < maxCount; ++iterator) { diff --git a/Modules/mesh-module/source/module.cpp b/Modules/mesh-module/source/module.cpp index 0f6c5a9cc..dbdc6a8fc 100644 --- a/Modules/mesh-module/source/module.cpp +++ b/Modules/mesh-module/source/module.cpp @@ -43,6 +43,11 @@ static const ModuleSymbol mesh_module_symbols[] = { DEFINE_MODULE_SYMBOL(mesh_set_node_name), DEFINE_MODULE_SYMBOL(mesh_get_node_name), DEFINE_MODULE_SYMBOL(mesh_is_available), + DEFINE_MODULE_SYMBOL(mesh_get_radio_count), + DEFINE_MODULE_SYMBOL(mesh_get_radio_name), + DEFINE_MODULE_SYMBOL(mesh_get_radio_kind), + DEFINE_MODULE_SYMBOL(mesh_set_radio), + DEFINE_MODULE_SYMBOL(mesh_get_radio), DEFINE_MODULE_SYMBOL(mesh_get_backend_count), DEFINE_MODULE_SYMBOL(mesh_get_backend_id), DEFINE_MODULE_SYMBOL(mesh_set_backend), @@ -56,9 +61,15 @@ static const ModuleSymbol mesh_module_symbols[] = { DEFINE_MODULE_SYMBOL(mesh_raw_send), DEFINE_MODULE_SYMBOL(mesh_message_get), DEFINE_MODULE_SYMBOL(mesh_history_get), + DEFINE_MODULE_SYMBOL(mesh_conversation_get), + DEFINE_MODULE_SYMBOL(mesh_get_contacts), + DEFINE_MODULE_SYMBOL(mesh_add_contact), + DEFINE_MODULE_SYMBOL(mesh_remove_contact), + DEFINE_MODULE_SYMBOL(mesh_get_contact_uri), DEFINE_MODULE_SYMBOL(mesh_get_nodes), DEFINE_MODULE_SYMBOL(mesh_get_channels), DEFINE_MODULE_SYMBOL(mesh_set_channel), + DEFINE_MODULE_SYMBOL(mesh_remove_channel), DEFINE_MODULE_SYMBOL(mesh_event_subscribe), DEFINE_MODULE_SYMBOL(mesh_event_unsubscribe), DEFINE_MODULE_SYMBOL(mesh_event_poll), diff --git a/Modules/mesh-module/tests/source/airtime_test.cpp b/Modules/mesh-module/tests/source/airtime_test.cpp index ae6bf7d97..7e26743ca 100644 --- a/Modules/mesh-module/tests/source/airtime_test.cpp +++ b/Modules/mesh-module/tests/source/airtime_test.cpp @@ -23,3 +23,10 @@ TEST_CASE("Duty cycle depends on region and sub-band") { CHECK_EQ(mesh_airtime_duty_cycle_percent(RADIO_REGION_EU_868, 868100000), 1); CHECK_EQ(mesh_airtime_duty_cycle_percent(RADIO_REGION_US_915, 906875000), 100); } + +TEST_CASE("ESP-NOW frames take milliseconds") { + MeshRadioProfile profile {}; + profile.modulation = RADIO_MODULATION_ESPNOW; + // 100 bytes of headers and 100 bytes of payload at 0.5 Mbit/s + CHECK_EQ(mesh_airtime_estimate_ms(profile, 100), 4); +} diff --git a/Modules/mesh-module/tests/source/service_test.cpp b/Modules/mesh-module/tests/source/service_test.cpp index a6d90277a..7b7c7a667 100644 --- a/Modules/mesh-module/tests/source/service_test.cpp +++ b/Modules/mesh-module/tests/source/service_test.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -62,7 +63,11 @@ void loopbackDestroy(void*) {} const char* const loopbackPresets[] = { "Default", "Other", nullptr }; -error_t loopbackGetRadioProfile(void*, RadioRegion, size_t preset, MeshRadioProfile* profile) { +error_t loopbackGetRadioProfile(void*, RadioRegion, RadioModulation modulation, size_t preset, MeshRadioProfile* profile) { + if (modulation == RADIO_MODULATION_ESPNOW) { + *profile = { .modulation = RADIO_MODULATION_ESPNOW, .power = 20, .channel = 6 }; + return ERROR_NONE; + } *profile = { .modulation = RADIO_MODULATION_LORA, .frequency = 869525000, @@ -101,6 +106,16 @@ bool loopbackOnReceive(void* backend, const uint8_t* data, size_t length, float void loopbackOnTick(void*) {} +error_t loopbackGetContactUri(void*, const MeshAddress* contact, const char* name, char* uri, size_t uriSize) { + snprintf(uri, uriSize, "loopback://%s/%02x", name, contact != nullptr ? contact->bytes[0] : 0); + return ERROR_NONE; +} + +bool loopbackIsValidContactAddress(void*, const MeshAddress* address) { return address->length == 1; } + +std::atomic nameChangeCount = 0; +void loopbackOnNodeNameChanged(void*) { nameChangeCount++; } + error_t loopbackSendText(void* backend, uint32_t seq, const MeshAddress*, uint8_t channel, const char* text) { uint8_t frame[1 + MESH_TEXT_MAX_LENGTH]; frame[0] = channel; @@ -121,6 +136,79 @@ const MeshBackendApi loopbackApi = { .send_text = loopbackSendText, .get_channels = nullptr, .set_channel = nullptr, + .get_contact_uri = loopbackGetContactUri, + .is_valid_contact_address = loopbackIsValidContactAddress, + .on_node_name_changed = loopbackOnNodeNameChanged, +}; + +// endregion + +// region A radio that only does ESP-NOW + +struct EspNowFake { + bool enabled = false; + RadioModulation modulation = RADIO_MODULATION_NONE; + int32_t channel = 0; + int32_t power = 0; +}; +EspNowFake espNowFake; + +error_t espNowGetState(Device*, RadioState* state) { + *state = espNowFake.enabled ? RADIO_STATE_ON : RADIO_STATE_OFF; + return ERROR_NONE; +} +error_t espNowSetEnabled(Device*, bool enabled) { + espNowFake.enabled = enabled; + return ERROR_NONE; +} +error_t espNowSetModulation(Device*, RadioModulation modulation) { + if (modulation != RADIO_MODULATION_ESPNOW) { + return ERROR_NOT_SUPPORTED; + } + espNowFake.modulation = modulation; + return ERROR_NONE; +} +error_t espNowGetModulation(Device*, RadioModulation* modulation) { + *modulation = espNowFake.modulation; + return ERROR_NONE; +} +bool espNowCanDo(Device*, RadioModulation modulation) { return modulation == RADIO_MODULATION_ESPNOW; } +error_t espNowSetParameter(Device*, RadioParameter parameter, int32_t value) { + if (parameter == RADIO_PARAMETER_CHANNEL) { + espNowFake.channel = value; + return ERROR_NONE; + } + if (parameter == RADIO_PARAMETER_POWER) { + espNowFake.power = value; + return ERROR_NONE; + } + return ERROR_NOT_SUPPORTED; +} +error_t espNowGetParameter(Device*, RadioParameter, int32_t*) { return ERROR_NOT_SUPPORTED; } +error_t espNowTransmit(Device*, const uint8_t*, size_t, RadioTxId*) { return ERROR_NONE; } + +const RadioApi espNowApi = { + .get_radio_state = espNowGetState, + .set_enabled = espNowSetEnabled, + .set_modulation = espNowSetModulation, + .get_modulation = espNowGetModulation, + .can_transmit = espNowCanDo, + .can_receive = espNowCanDo, + .set_parameter = espNowSetParameter, + .get_parameter = espNowGetParameter, + .transmit = espNowTransmit, + .get_rssi = nullptr, + .is_receiving = nullptr, +}; + +Driver espNowDriver = { + .name = "mesh_test_espnow_driver", + .compatible = (const char*[]) { "mesh_test,espnow", nullptr }, + .start_device = nullptr, + .stop_device = nullptr, + .api = &espNowApi, + .device_type = &RADIO_TYPE, + .internal = nullptr, }; // endregion @@ -209,6 +297,92 @@ TEST_CASE("mesh service sends and receives through the active backend") { REQUIRE_EQ(mesh_set_preset("Default"), ERROR_NONE); } + SUBCASE("a new node name is announced by the backend") { + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + + // Without a chosen name, a random one is made up once + char generated[MESH_NAME_MAX_LENGTH + 1]; + CHECK_EQ(mesh_get_node_name(generated, sizeof(generated)), ERROR_NONE); + const std::string generatedName = generated; + REQUIRE_EQ(generatedName.size(), std::string("TactilityUser-").size() + 8); + CHECK_EQ(generatedName.substr(0, 14), "TactilityUser-"); + CHECK_EQ(generatedName.substr(14).find_first_not_of("0123456789abcdef"), std::string::npos); + // The service keeps its settings for the whole process, so the data folder of this test is read directly + MeshSettings first; + MeshSettings second; + mesh_settings_load(&first); + mesh_settings_load(&second); + CHECK_EQ(std::string(first.node_name).substr(0, 14), "TactilityUser-"); + CHECK_EQ(std::string(first.node_name), std::string(second.node_name)); + + const int before = nameChangeCount; + CHECK_EQ(mesh_set_node_name(""), ERROR_INVALID_ARGUMENT); + CHECK_EQ(mesh_set_node_name("Ken's deck"), ERROR_NONE); + char name[MESH_NAME_MAX_LENGTH + 1]; + CHECK_EQ(mesh_get_node_name(name, sizeof(name)), ERROR_NONE); + CHECK_EQ(std::string(name), "Ken's deck"); + CHECK(waitFor([before] { return nameChangeCount > before; })); + } + + SUBCASE("the radio can be selected") { + static Device espNow { .name = "radio_espnow", .config = nullptr, .parent = nullptr }; + espNowFake = {}; + REQUIRE_EQ(driver_construct_add(&espNowDriver), ERROR_NONE); + REQUIRE_EQ(device_construct_add(&espNow, "mesh_test,espnow"), ERROR_NONE); + REQUIRE_EQ(device_start(&espNow), ERROR_NONE); + REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE(waitFor([] { return mesh_get_state() == MESH_STATE_ON; })); + + // The default is the first radio that isn't ESP-NOW + CHECK_EQ(mesh_get_radio_count(), 3); + char name[MESH_NAME_MAX_LENGTH + 1]; + CHECK_EQ(mesh_get_radio_name(2, name, sizeof(name)), ERROR_NONE); + CHECK_EQ(std::string(name), "radio_espnow"); + CHECK_EQ(mesh_get_radio_name(3, name, sizeof(name)), ERROR_NOT_FOUND); + MeshRadioKind kind; + CHECK_EQ(mesh_get_radio_kind(0, &kind), ERROR_NONE); + CHECK_EQ(kind, MESH_RADIO_LORA); + CHECK_EQ(mesh_get_radio_kind(2, &kind), ERROR_NONE); + CHECK_EQ(kind, MESH_RADIO_ESPNOW); + CHECK_EQ(mesh_get_radio_kind(3, &kind), ERROR_NOT_FOUND); + CHECK_EQ(mesh_get_radio(name, sizeof(name)), ERROR_NONE); + CHECK_EQ(std::string(name), "radio_local"); + CHECK_EQ(mesh_get_preset_count(), 2); + CHECK_FALSE(espNowFake.enabled); + + CHECK_EQ(mesh_set_radio("missing"), ERROR_NOT_FOUND); + + // A radio that doesn't do LoRa gets the ESP-NOW profile of the backend, and has no presets + REQUIRE_EQ(mesh_set_radio("radio_espnow"), ERROR_NONE); + CHECK(waitFor([] { return espNowFake.enabled && mesh_get_state() == MESH_STATE_ON; })); + CHECK_EQ(espNowFake.modulation, RADIO_MODULATION_ESPNOW); + CHECK_EQ(espNowFake.channel, 6); + CHECK_EQ(espNowFake.power, 20); + CHECK_EQ(mesh_get_radio(name, sizeof(name)), ERROR_NONE); + CHECK_EQ(std::string(name), "radio_espnow"); + CHECK_EQ(mesh_get_preset_count(), 0); + CHECK_EQ(radio_acquire(&local, "someone-else"), ERROR_NONE); + CHECK_EQ(radio_release(&local), ERROR_NONE); + + // Back to the default + REQUIRE_EQ(mesh_set_radio(""), ERROR_NONE); + CHECK(waitFor([] { return !espNowFake.enabled && mesh_get_state() == MESH_STATE_ON; })); + CHECK_EQ(mesh_get_radio(name, sizeof(name)), ERROR_NONE); + CHECK_EQ(std::string(name), "radio_local"); + CHECK_EQ(mesh_get_preset_count(), 2); + + REQUIRE_EQ(service_manager_stop(MESH_SERVICE_ID), ERROR_NONE); + REQUIRE_EQ(radio_set_region(&espNow, RADIO_REGION_UNSET), ERROR_NONE); + REQUIRE_EQ(device_stop(&espNow), ERROR_NONE); + REQUIRE_EQ(device_remove(&espNow), ERROR_NONE); + REQUIRE_EQ(device_destruct(&espNow), ERROR_NONE); + REQUIRE_EQ(driver_remove_destruct(&espNowDriver), ERROR_NONE); + REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); + } + SUBCASE("concurrent synchronous calls finish") { REQUIRE_EQ(radio_set_region(&local, RADIO_REGION_EU_868), ERROR_NONE); REQUIRE_EQ(service_manager_start(MESH_SERVICE_ID), ERROR_NONE); @@ -248,7 +422,7 @@ TEST_CASE("mesh service sends and receives through the active backend") { // Tune the remote radio like the backend profile MeshRadioProfile profile; - loopbackGetRadioProfile(nullptr, RADIO_REGION_EU_868, 0, &profile); + loopbackGetRadioProfile(nullptr, RADIO_REGION_EU_868, RADIO_MODULATION_LORA, 0, &profile); REQUIRE_EQ(radio_set_modulation(&remote, profile.modulation), ERROR_NONE); radio_set_parameter(&remote, RADIO_PARAMETER_FREQUENCY, profile.frequency); radio_set_parameter(&remote, RADIO_PARAMETER_BANDWIDTH, profile.bandwidth); @@ -325,6 +499,69 @@ TEST_CASE("mesh service sends and receives through the active backend") { CHECK(mesh_history_get(0, history, 4, &count) == ERROR_NONE); CHECK_EQ(count, 2); + // The sender of the incoming message was saved as a contact + MeshContact contacts[4]; + REQUIRE_EQ(mesh_get_contacts(contacts, 4, &count), ERROR_NONE); + REQUIRE_EQ(count, 1); + CHECK_EQ(std::string(contacts[0].name), "peer"); + CHECK_EQ(std::string(contacts[0].backend), "loopback"); + + // Contacts can be added, renamed and removed + const MeshAddress alice = { .length = 1, .bytes = { 0x50 } }; + const MeshAddress unknown = { .length = 1, .bytes = { 0x51 } }; + const MeshAddress tooLong = { .length = 2, .bytes = { 0x52, 0x53 } }; + CHECK_EQ(mesh_add_contact(&tooLong, "bad"), ERROR_INVALID_ARGUMENT); + CHECK_EQ(mesh_add_contact(&alice, "alice"), ERROR_NONE); + CHECK_EQ(mesh_add_contact(&alice, nullptr), ERROR_NONE); + CHECK_EQ(mesh_add_contact(&unknown, nullptr), ERROR_NONE); + REQUIRE_EQ(mesh_get_contacts(contacts, 4, &count), ERROR_NONE); + REQUIRE_EQ(count, 3); + CHECK_EQ(std::string(contacts[1].name), "alice"); + // Without a name the address identifies the contact + CHECK_EQ(std::string(contacts[2].name), "51"); + + // Conversations only hold their own messages + uint32_t directSeq = 0; + uint32_t channelSeq = 0; + REQUIRE_EQ(mesh_send_text(&alice, 0, "hi alice", &directSeq), ERROR_NONE); + REQUIRE_EQ(mesh_send_text(nullptr, 5, "hello channel", &channelSeq), ERROR_NONE); + MeshMessage conversation[4]; + CHECK_EQ(mesh_conversation_get(&alice, 0, 0, conversation, 4, &count), ERROR_NONE); + REQUIRE_EQ(count, 1); + CHECK_EQ(conversation[0].seq, directSeq); + CHECK_EQ(std::string(conversation[0].text), "hi alice"); + CHECK_EQ(mesh_conversation_get(nullptr, 5, 0, conversation, 4, &count), ERROR_NONE); + REQUIRE_EQ(count, 1); + CHECK_EQ(conversation[0].seq, channelSeq); + CHECK_EQ(mesh_conversation_get(nullptr, 6, 0, conversation, 4, &count), ERROR_NONE); + CHECK_EQ(count, 0); + CHECK_EQ(mesh_conversation_get(&unknown, 0, 0, conversation, 4, &count), ERROR_NONE); + CHECK_EQ(count, 0); + // The delivery state shows how far the message got + CHECK(waitFor([directSeq] { return getDelivery(directSeq) == MESH_DELIVERY_SENT; })); + + // The URI of a saved contact and of this node come from the backend + char uri[64]; + CHECK_EQ(mesh_get_contact_uri(&alice, uri, sizeof(uri)), ERROR_NONE); + CHECK_EQ(std::string(uri), "loopback://alice/50"); + CHECK_EQ(mesh_get_contact_uri(nullptr, uri, sizeof(uri)), ERROR_NONE); + CHECK_EQ(std::string(uri).substr(0, 11), "loopback://"); + CHECK_EQ(mesh_get_contact_uri(&unknown, uri, sizeof(uri)), ERROR_NONE); + const MeshAddress stranger = { .length = 1, .bytes = { 0x77 } }; + CHECK_EQ(mesh_get_contact_uri(&stranger, uri, sizeof(uri)), ERROR_NOT_FOUND); + + CHECK_EQ(mesh_remove_contact(&alice), ERROR_NONE); + CHECK_EQ(mesh_remove_contact(&alice), ERROR_NOT_FOUND); + REQUIRE_EQ(mesh_get_contacts(contacts, 4, &count), ERROR_NONE); + CHECK_EQ(count, 2); + // The messages remain + CHECK_EQ(mesh_conversation_get(&alice, 0, 0, conversation, 4, &count), ERROR_NONE); + CHECK_EQ(count, 1); + + // The primary channel stays, and the loopback backend has no channels + CHECK_EQ(mesh_remove_channel(0), ERROR_INVALID_ARGUMENT); + CHECK_EQ(mesh_remove_channel(2), ERROR_NOT_SUPPORTED); + CHECK_EQ(mesh_cancel(seq), ERROR_INVALID_STATE); CHECK_EQ(mesh_raw_send("other", reply, sizeof(reply)), ERROR_INVALID_STATE); CHECK_EQ(mesh_raw_send("loopback", reply, sizeof(reply)), ERROR_NONE); diff --git a/Modules/mesh-module/tests/source/store_test.cpp b/Modules/mesh-module/tests/source/store_test.cpp index ab77535a9..b875e967f 100644 --- a/Modules/mesh-module/tests/source/store_test.cpp +++ b/Modules/mesh-module/tests/source/store_test.cpp @@ -220,3 +220,51 @@ TEST_CASE("MeshStore keeps a full budget of segments when it recovers from a tor CHECK_EQ(reopened.get(message.seq, &read), ERROR_NONE); CHECK_EQ(std::string(read.text), "after"); } + +TEST_CASE("MeshStore reads the messages of one conversation") { + const std::string directory = makeTempDirectory(); + const MeshAddress alice = { .length = 4, .bytes = { 1, 2, 3, 4 } }; + const MeshAddress bob = { .length = 4, .bytes = { 5, 6, 7, 8 } }; + + const auto add = [](MeshStore& store, const char* backend, const MeshAddress* peer, uint8_t channel, const char* text) { + auto message = makeMessage(text, false, MESH_DELIVERY_RECEIVED); + strcpy(message.backend, backend); + message.peer = (peer != nullptr) ? *peer : MeshAddress {}; + message.channel = channel; + REQUIRE_EQ(store.add(&message), ERROR_NONE); + }; + + // The index is built from the log after a reopen, and from the buffer before it + for (int round = 0; round < 2; round++) { + MeshStore store; + REQUIRE_EQ(store.open(directory.c_str()), ERROR_NONE); + if (round == 0) { + add(store, "test", &alice, 0, "alice 1"); + add(store, "test", nullptr, 0, "channel 0"); + add(store, "test", &bob, 0, "bob 1"); + add(store, "test", &alice, 0, "alice 2"); + add(store, "other", &alice, 0, "other backend"); + add(store, "test", nullptr, 3, "channel 3"); + } + + MeshMessage messages[8]; + REQUIRE_EQ(store.getConversation("test", &alice, 0, 0, messages, 8), 2); + CHECK_EQ(std::string(messages[0].text), "alice 2"); + CHECK_EQ(std::string(messages[1].text), "alice 1"); + + REQUIRE_EQ(store.getConversation("test", nullptr, 3, 0, messages, 8), 1); + CHECK_EQ(std::string(messages[0].text), "channel 3"); + REQUIRE_EQ(store.getConversation("test", nullptr, 0, 0, messages, 8), 1); + CHECK_EQ(std::string(messages[0].text), "channel 0"); + CHECK_EQ(store.getConversation("test", nullptr, 7, 0, messages, 8), 0); + + REQUIRE_EQ(store.getConversation("other", &alice, 0, 0, messages, 8), 1); + CHECK_EQ(std::string(messages[0].text), "other backend"); + + // Paging back from a message + REQUIRE_EQ(store.getConversation("test", &alice, 0, 4, messages, 8), 1); + CHECK_EQ(std::string(messages[0].text), "alice 1"); + CHECK_EQ(store.getConversation("test", &alice, 0, 0, messages, 1), 1); + store.close(); + } +} diff --git a/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h b/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h index 7a658e0e7..695caad48 100644 --- a/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h +++ b/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h @@ -11,13 +11,13 @@ extern "C" { /** * Shared ownership over the single ESP32 WiFi radio's esp_wifi_init()/esp_wifi_start() lifecycle. - * Station mode (esp32_wifi driver) and the WebServer's Access Point mode both need the radio and + * Station mode (esp32_wifi driver), ESP-NOW and the WebServer's Access Point mode need the radio and * must not blindly call esp_wifi_init()/esp_wifi_deinit() themselves - doing so unconditionally * breaks whichever owner got there second (failing outright with ESP_ERR_INVALID_STATE, or having * its radio torn out from under it when the other side releases). Callers acquire the mode bit they * need (WIFI_MODE_STA or WIFI_MODE_AP); the radio is brought up on the first acquire (merging into * WIFI_MODE_APSTA if another owner is already active) and only torn down once every owner has - * released. + * released. Several owners can hold the same mode bit, and the bit stays active until its last owner released. * * @param mode_bit WIFI_MODE_STA or WIFI_MODE_AP (not WIFI_MODE_APSTA/WIFI_MODE_NULL). * @retval true the mode bit is now active (radio is initialized, started, and its current mode @@ -27,6 +27,12 @@ extern "C" { */ bool esp32_wifi_radio_acquire(wifi_mode_t mode_bit); +/** + * Same as esp32_wifi_radio_acquire(). + * @param[out] was_started true when another owner had the radio running before this call, decided under the same lock as the acquire + */ +bool esp32_wifi_radio_acquire_ex(wifi_mode_t mode_bit, bool* was_started); + /** * Releases a previously-acquired mode bit. If this was the last owner (resulting mode would be * WIFI_MODE_NULL), fully stops and deinitializes the radio; otherwise just drops that bit from the @@ -37,6 +43,9 @@ bool esp32_wifi_radio_acquire(wifi_mode_t mode_bit); */ void esp32_wifi_radio_release(wifi_mode_t mode_bit); +/** @return true when an owner holds the radio, so it is initialized and started */ +bool esp32_wifi_radio_is_started(void); + #ifdef __cplusplus } #endif diff --git a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp index 4d2d79e82..d06de22d5 100644 --- a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp +++ b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp @@ -30,6 +30,7 @@ #include #include +#include #include #define TAG "esp32_wifi" @@ -76,6 +77,43 @@ struct Esp32WifiCtx { Mutex g_radioMutex{}; int g_radioOwnerCount = 0; wifi_mode_t g_radioMode = WIFI_MODE_NULL; +// Owners per mode bit, as several owners can share one: a bit leaves the mode when its last owner released +int g_staOwnerCount = 0; +int g_apOwnerCount = 0; + +// Netifs that were started and not stopped or destroyed since +std::mutex g_startedNetifMutex; +const void* g_startedNetifs[4] = {}; + +bool mark_netif_started(const void* netif) { + std::lock_guard lock(g_startedNetifMutex); + const void** free_slot = nullptr; + for (auto& slot : g_startedNetifs) { + if (slot == netif) { + return false; + } + if (slot == nullptr && free_slot == nullptr) { + free_slot = &slot; + } + } + if (free_slot != nullptr) { + *free_slot = netif; + } + return true; +} + +void mark_netif_stopped(const void* netif) { + std::lock_guard lock(g_startedNetifMutex); + for (auto& slot : g_startedNetifs) { + if (slot == netif) { + slot = nullptr; + } + } +} + +int& modeOwnerCount(wifi_mode_t mode_bit) { + return mode_bit == WIFI_MODE_AP ? g_apOwnerCount : g_staOwnerCount; +} WifiAuthenticationType to_wifi_authentication_type(wifi_auth_mode_t mode) { switch (mode) { @@ -197,9 +235,13 @@ error_t bring_up_wifi(Esp32WifiCtx* ctx) { return ERROR_RESOURCE; } + // Another owner (e.g. ESP-NOW) can have the radio running already. Its WIFI_EVENT_STA_START has been + // delivered by then, or is still on its way. If it was delivered, nothing starts the new netif, so it's started below. + bool radio_already_started = false; + // Warning: this is the memory-intensive operation. It uses over 100kB of // RAM with default settings. - if (!esp32_wifi_radio_acquire(WIFI_MODE_STA)) { + if (!esp32_wifi_radio_acquire_ex(WIFI_MODE_STA, &radio_already_started)) { LOG_E(TAG, "Failed to acquire WiFi radio for station mode"); esp_netif_destroy(ctx->netif); ctx->netif = nullptr; @@ -228,6 +270,11 @@ error_t bring_up_wifi(Esp32WifiCtx* ctx) { return esp_err_to_error(err); } + // A start event that is still on its way starts the netif too, which __wrap_esp_netif_action_start() ignores + if (radio_already_started) { + esp_netif_action_start(ctx->netif, WIFI_EVENT, WIFI_EVENT_STA_START, nullptr); + } + mutex_lock(&ctx->mutex); ctx->radioState = WIFI_RADIO_STATE_ON; mutex_unlock(&ctx->mutex); @@ -647,9 +694,15 @@ error_t stop_device(Device* device) { extern "C" { bool esp32_wifi_radio_acquire(wifi_mode_t mode_bit) { + bool was_started; + return esp32_wifi_radio_acquire_ex(mode_bit, &was_started); +} + +bool esp32_wifi_radio_acquire_ex(wifi_mode_t mode_bit, bool* was_started) { mutex_lock(&g_radioMutex); bool first_owner = g_radioOwnerCount == 0; + *was_started = !first_owner; if (first_owner) { wifi_init_config_t init_config = WIFI_INIT_CONFIG_DEFAULT(); esp_err_t err = esp_wifi_init(&init_config); @@ -661,7 +714,8 @@ bool esp32_wifi_radio_acquire(wifi_mode_t mode_bit) { } auto new_mode = static_cast(g_radioMode | mode_bit); - esp_err_t err = esp_wifi_set_mode(new_mode); + // Setting the same mode again can make the station restart, which sends another WIFI_EVENT_STA_START + esp_err_t err = (new_mode == g_radioMode && !first_owner) ? ESP_OK : esp_wifi_set_mode(new_mode); if (err != ESP_OK) { LOG_E(TAG, "esp32_wifi_radio_acquire: esp_wifi_set_mode failed: %s", esp_err_to_name(err)); if (first_owner) { @@ -684,10 +738,18 @@ bool esp32_wifi_radio_acquire(wifi_mode_t mode_bit) { g_radioMode = new_mode; g_radioOwnerCount++; + modeOwnerCount(mode_bit)++; mutex_unlock(&g_radioMutex); return true; } +bool esp32_wifi_radio_is_started(void) { + mutex_lock(&g_radioMutex); + const bool started = g_radioOwnerCount > 0; + mutex_unlock(&g_radioMutex); + return started; +} + void esp32_wifi_radio_release(wifi_mode_t mode_bit) { mutex_lock(&g_radioMutex); @@ -696,7 +758,11 @@ void esp32_wifi_radio_release(wifi_mode_t mode_bit) { return; // Guards against a mismatched acquire/release pair. } - g_radioMode = static_cast(g_radioMode & ~mode_bit); + const wifi_mode_t previous_mode = g_radioMode; + if (--modeOwnerCount(mode_bit) <= 0) { + modeOwnerCount(mode_bit) = 0; + g_radioMode = static_cast(g_radioMode & ~mode_bit); + } g_radioOwnerCount--; if (g_radioOwnerCount <= 0) { @@ -704,14 +770,41 @@ void esp32_wifi_radio_release(wifi_mode_t mode_bit) { esp_wifi_set_mode(WIFI_MODE_NULL); esp_wifi_deinit(); g_radioOwnerCount = 0; + g_staOwnerCount = 0; + g_apOwnerCount = 0; g_radioMode = WIFI_MODE_NULL; - } else { + } else if (g_radioMode != previous_mode) { esp_wifi_set_mode(g_radioMode); } mutex_unlock(&g_radioMutex); } +// Starting a netif twice asserts in lwip, and the co-processor can send WIFI_EVENT_STA_START more than once +// (e.g. when ESP-NOW and WiFi both start the radio). These wrappers make starting a netif idempotent. +// They are linked in with -Wl,--wrap in the top-level CMakeLists.txt. +extern void __real_esp_netif_action_start(void* esp_netif, esp_event_base_t base, int32_t event_id, void* data); +extern void __real_esp_netif_action_stop(void* esp_netif, esp_event_base_t base, int32_t event_id, void* data); +extern void __real_esp_netif_destroy(esp_netif_t* esp_netif); + +void __wrap_esp_netif_action_start(void* esp_netif, esp_event_base_t base, int32_t event_id, void* data) { + if (!mark_netif_started(esp_netif)) { + LOG_W(TAG, "Ignoring a duplicate start of a netif"); + return; + } + __real_esp_netif_action_start(esp_netif, base, event_id, data); +} + +void __wrap_esp_netif_action_stop(void* esp_netif, esp_event_base_t base, int32_t event_id, void* data) { + mark_netif_stopped(esp_netif); + __real_esp_netif_action_stop(esp_netif, base, event_id, data); +} + +void __wrap_esp_netif_destroy(esp_netif_t* esp_netif) { + mark_netif_stopped(esp_netif); + __real_esp_netif_destroy(esp_netif); +} + Driver esp32_wifi_driver = { .name = "esp32_wifi", .compatible = (const char*[]) { "espressif,esp32-wifi", nullptr }, diff --git a/Tactility/CMakeLists.txt b/Tactility/CMakeLists.txt index 07eba82e9..96a5a2095 100644 --- a/Tactility/CMakeLists.txt +++ b/Tactility/CMakeLists.txt @@ -240,16 +240,3 @@ message("Default font size: ${font_default_size}") target_compile_definitions(${COMPONENT_LIB} PUBLIC "-DTT_FONT_DEFAULT_SIZE=${font_default_size}" ) - -# The mesh stack is only linked into firmware for devices with "hardware.mesh=true", to save flash elsewhere -set(tt_feature_mesh 1) -if (DEFINED ENV{ESP_IDF_VERSION}) - file(READ "${DEVICETREE_LOCATION}/device.properties" mesh_device_properties) - if (NOT mesh_device_properties MATCHES "(^|\n)hardware\\.mesh=true") - set(tt_feature_mesh 0) - endif () -endif () -message("Mesh: ${tt_feature_mesh}") -target_compile_definitions(${COMPONENT_LIB} PUBLIC - "-DTT_FEATURE_MESH=${tt_feature_mesh}" -) diff --git a/Tactility/Include/Tactility/app/inputdialog/InputDialog.h b/Tactility/Include/Tactility/app/inputdialog/InputDialog.h index ee42c7556..037011504 100644 --- a/Tactility/Include/Tactility/app/inputdialog/InputDialog.h +++ b/Tactility/Include/Tactility/app/inputdialog/InputDialog.h @@ -20,6 +20,7 @@ namespace tt::app::inputdialog { * app_stream_unsubscribe(&stream); on any other result, just app_stream_unsubscribe(&stream). * The caller must call app_manager_stop() on the returned instance id once that event arrives, * to fully reap this instance. + * The first button (OK) is the positive button. The Enter key in the text field presses it. * @param[in,out] stream caller-owned storage bound to the started app's stdout; must stay valid * until app_stream_unsubscribe() is called on it (see above). * @param[in] buffer caller-owned backing storage for @a stream's ring buffer; must stay valid diff --git a/Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h b/Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h new file mode 100644 index 000000000..ee1f2a886 --- /dev/null +++ b/Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h @@ -0,0 +1,72 @@ +#pragma once + + +#include + +#include + +#include +#include +#include +#include + +/** A macro, because the manifest's id is a char array that needs a string literal */ +#define CONVERSATION_APP_ID "tactility.meshconversation" +#define ADD_CONTACT_APP_ID "tactility.meshaddcontact" +#define MESH_SETTINGS_APP_ID "tactility.meshsettings" + +namespace tt::app::meshchat { + +/** Arguments of the conversation app: [0] kind, [1] contact address in hex or channel index, [2] title */ +constexpr auto* KIND_CONTACT = "contact"; +constexpr auto* KIND_CHANNEL = "channel"; + +/** Explains why the mesh service can't do anything, e.g. no region set or the radio failed to start */ +inline ::std::string getNotReadyText() { + return ::std::string("Mesh isn't running: ") + mesh_state_to_string(mesh_get_state()) + ". Check the Mesh settings."; +} + +/** Starts an app by id with the given arguments */ +void startApp(const char* id, int argc, const char* const argv[]); + +/** A scrollable, borderless column below the toolbar and status label that holds the lists and buttons of a screen */ +lv_obj_t* createContent(lv_obj_t* parent); + +/** + * A card that holds list items. The items are direct children of the card, + * because every nesting level adds to the LVGL task's draw stack. + * Its margin is the theme's container padding. + * @param[in] parent the result of createContent() + */ +lv_obj_t* createCard(lv_obj_t* parent); + +inline ::std::string addressToHex(const MeshAddress& address) { + ::std::string text; + char hex[3]; + for (size_t i = 0; i < address.length; i++) { + snprintf(hex, sizeof(hex), "%02x", address.bytes[i]); + text += hex; + } + return text; +} + +inline bool hexToAddress(const char* hex, MeshAddress* address) { + const size_t length = strlen(hex); + if (length == 0 || length % 2 != 0 || length / 2 > MESH_ADDRESS_MAX_LENGTH) { + return false; + } + *address = {}; + for (size_t i = 0; i < length / 2; i++) { + char pair[3] = { hex[i * 2], hex[i * 2 + 1], '\0' }; + char* end = nullptr; + address->bytes[i] = (uint8_t)strtoul(pair, &end, 16); + if (end != pair + 2) { + return false; + } + } + address->length = (uint8_t)(length / 2); + return true; +} + +} + diff --git a/Tactility/Include/Tactility/app/meshsettings/MeshChannels.h b/Tactility/Include/Tactility/app/meshsettings/MeshChannels.h new file mode 100644 index 000000000..df0f7265b --- /dev/null +++ b/Tactility/Include/Tactility/app/meshsettings/MeshChannels.h @@ -0,0 +1,33 @@ +#pragma once + + +#include + +#include +#include + +namespace tt::app::meshsettings { + +/** + * Join a hashtag channel (e.g. "#test") of the active mesh backend on the next free channel index. + * Must not be called with the LVGL lock held. + * @retval ERROR_INVALID_ARGUMENT for an empty name, or a name the backend can't derive a key for + */ +error_t joinHashtagChannel(const char* name); + +/** + * Create a private channel on the next free channel index of the active mesh backend, with a random key. + * Must not be called with the LVGL lock held. + * @retval ERROR_INVALID_ARGUMENT for an empty name + */ +error_t createPrivateChannel(const char* name); + +/** + * Join a private channel with a known key on the next free channel index of the active mesh backend. + * Must not be called with the LVGL lock held. + * @retval ERROR_INVALID_ARGUMENT for an empty name, or a key the backend doesn't accept + */ +error_t joinPrivateChannel(const char* name, const uint8_t* key, size_t keyLength); + +} + diff --git a/Tactility/Include/Tactility/app/qrcode/QrCode.h b/Tactility/Include/Tactility/app/qrcode/QrCode.h new file mode 100644 index 000000000..5c83d6aae --- /dev/null +++ b/Tactility/Include/Tactility/app/qrcode/QrCode.h @@ -0,0 +1,18 @@ +#pragma once + +#include "app/instance.h" + +#include + +/** + * Show a QR code over the whole app area. + */ +namespace tt::app::qrcode { + +/** + * Show a QR code of @a text. It closes on a touch, a key press or encoder input. + * @return the new app's instance id, or 0 when it couldn't be started + */ +AppInstanceId start(const std::string& text); + +} diff --git a/Tactility/Private/Tactility/app/TileGrid.h b/Tactility/Private/Tactility/app/TileGrid.h index 05be0d8b6..c009e4365 100644 --- a/Tactility/Private/Tactility/app/TileGrid.h +++ b/Tactility/Private/Tactility/app/TileGrid.h @@ -140,6 +140,8 @@ private: void goToPreviousPage(); void goToNextPage(); void populate(); + bool isPagingVisible() const; + void setPagingVisible(bool visible); static void onDeferredRepopulate(void* userData); static void onDeferredResize(void* userData); diff --git a/Tactility/Private/Tactility/service/espnow/EspNowRadio.h b/Tactility/Private/Tactility/service/espnow/EspNowRadio.h new file mode 100644 index 000000000..a1aa3d05e --- /dev/null +++ b/Tactility/Private/Tactility/service/espnow/EspNowRadio.h @@ -0,0 +1,19 @@ +#pragma once + +#ifdef ESP_PLATFORM +#include +#endif + +#if (defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)) + +namespace tt::service::espnow { + +/** + * Adds a radio device (RADIO_TYPE) that sends and receives broadcast frames with ESP-NOW, so that the mesh service + * can use it like a LoRa radio. It uses the ESP-NOW service, so it must be registered after that service. + */ +void registerRadio(); + +} + +#endif diff --git a/Tactility/Source/InitApps.cpp b/Tactility/Source/InitApps.cpp index 02b138b93..158bbe064 100644 --- a/Tactility/Source/InitApps.cpp +++ b/Tactility/Source/InitApps.cpp @@ -60,16 +60,20 @@ namespace app { namespace notes { extern const ::AppManifest manifest; } namespace power { extern const ::AppManifest manifest; } namespace poweroff { extern const ::AppManifest manifest; } + namespace qrcode { extern const ::AppManifest manifest; } namespace selectiondialog { extern const ::AppManifest manifest; } namespace setup { extern const ::AppManifest manifest; } namespace shell { extern const ::AppManifest manifest; extern const ::AppManifest sh_manifest; } namespace systeminfo { extern const ::AppManifest manifest; } namespace terminal { extern const ::AppManifest manifest; } namespace timedatesettings { extern const ::AppManifest manifest; } -#if TT_FEATURE_MESH - namespace meshinspect { extern const ::AppManifest manifest; } + namespace meshchat { + extern const ::AppManifest contactsManifest; + extern const ::AppManifest channelsManifest; + extern const ::AppManifest conversationManifest; + extern const ::AppManifest addContactManifest; + } namespace meshsettings { extern const ::AppManifest manifest; } -#endif #ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED namespace touchcalibration { extern const ::AppManifest manifest; } #endif @@ -132,6 +136,7 @@ static void registerInternalApps() { if (device_exists_of_type(&POWER_SUPPLY_TYPE)) { app_manager_add(&app::poweroff::manifest); } + app_manager_add(&app::qrcode::manifest); app_manager_add(&app::selectiondialog::manifest); app_manager_add(&app::setup::manifest); app_manager_add(&app::shell::manifest); @@ -164,12 +169,13 @@ static void registerInternalApps() { app_manager_add(&app::trackballsettings::manifest); } -#if TT_FEATURE_MESH if (settings::radio::hasRadio()) { - app_manager_add(&app::meshinspect::manifest); + app_manager_add(&app::meshchat::contactsManifest); + app_manager_add(&app::meshchat::channelsManifest); + app_manager_add(&app::meshchat::conversationManifest); + app_manager_add(&app::meshchat::addContactManifest); app_manager_add(&app::meshsettings::manifest); } -#endif #if defined(CONFIG_TINYUSB_MSC_ENABLED) && CONFIG_TINYUSB_MSC_ENABLED app_manager_add(&app::usbsettings::manifest); diff --git a/Tactility/Source/InitServices.cpp b/Tactility/Source/InitServices.cpp index f540112bd..79fa3e8ab 100644 --- a/Tactility/Source/InitServices.cpp +++ b/Tactility/Source/InitServices.cpp @@ -6,17 +6,16 @@ #include #include +#include #include #include #include #include #include -#if TT_FEATURE_MESH #include #include #include -#endif #if defined(__linux__) || defined(__APPLE__) #include @@ -55,11 +54,9 @@ void registerAndStartServices() { check(module_ensure_started(&app_hub_module) == ERROR_NONE); // Before the mesh service starts, as it needs the region to turn on the radio settings::radio::applyRegion(); -#if TT_FEATURE_MESH check(module_ensure_started(&mesh_module) == ERROR_NONE); check(module_ensure_started(&mesh_meshcore_module) == ERROR_NONE); check(module_ensure_started(&mesh_meshtastic_module) == ERROR_NONE); -#endif check(module_ensure_started(&development_module) == ERROR_NONE); addService(service::webserver::manifest); #if defined(__linux__) || defined(__APPLE__) @@ -68,6 +65,8 @@ void registerAndStartServices() { #if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED) addService(service::espnow::manifest); + // Lets mesh networking run over ESP-NOW + service::espnow::registerRadio(); #endif #if defined(ESP_PLATFORM) if (device_exists_of_type(&RTC_TYPE)) { diff --git a/Tactility/Source/app/TileGrid.cpp b/Tactility/Source/app/TileGrid.cpp index 8ed110ddd..eb6d1b0bf 100644 --- a/Tactility/Source/app/TileGrid.cpp +++ b/Tactility/Source/app/TileGrid.cpp @@ -273,6 +273,23 @@ void TileGrid::requestRepopulate(bool keepSelection) { lv_async_call(onDeferredRepopulate, this); } +bool TileGrid::isPagingVisible() const { + if (pageIndicator != nullptr) { + return !lv_obj_is_hidden(pageIndicator); + } + return prevButton != nullptr && !lv_obj_is_hidden(prevButton); +} + +void TileGrid::setPagingVisible(bool visible) { + if (pageIndicator != nullptr) { + lv_obj_set_hidden(pageIndicator, !visible); + } + if (prevButton != nullptr) { + lv_obj_set_hidden(prevButton, !visible); + lv_obj_set_hidden(nextButton, !visible); + } +} + void TileGrid::populate() { // Captured before lv_obj_clean() deletes the currently focused tile below: creating // replacement tiles doesn't restore the focus, so the item id is remembered here and @@ -295,13 +312,24 @@ void TileGrid::populate() { // Centers a full page of rows, so a partially filled last page keeps its rows in the same positions. lv_obj_set_style_pad_top(grid, 0, LV_STATE_DEFAULT); - const PageLayout layout = computePageLayout(grid); + PageLayout layout = computePageLayout(grid); + const uint32_t item_count = items.size(); + uint32_t page_count = std::max(1, (item_count + layout.pageSize - 1) / layout.pageSize); + + // The page indicator and buttons take room from the grid, and are only shown with multiple pages. + // Showing them changes the grid size and so the page size, so the layout is computed again with them in place. + const bool wasPagingVisible = isPagingVisible(); + setPagingVisible(page_count > 1); + if (wasPagingVisible != (page_count > 1)) { + lv_obj_update_layout(lv_obj_get_parent(grid)); + layout = computePageLayout(grid); + page_count = std::max(1, (item_count + layout.pageSize - 1) / layout.pageSize); + setPagingVisible(page_count > 1); + } populatedWidth = lv_obj_get_width(grid); populatedHeight = lv_obj_get_height(grid); lv_obj_set_style_pad_top(grid, layout.topOffset, LV_STATE_DEFAULT); - const uint32_t item_count = items.size(); - const uint32_t page_count = std::max(1, (item_count + layout.pageSize - 1) / layout.pageSize); // Follows the focused item onto its new page, e.g. after a reorder moved it. if (!focusedId.empty()) { const auto it = std::ranges::find_if(items, [&](const TileGridItem& item) { @@ -315,14 +343,9 @@ void TileGrid::populate() { pageCount = page_count; if (pageIndicator != nullptr) { - lv_obj_set_hidden(pageIndicator, page_count <= 1); lvgl_page_indicator_set_page_count(pageIndicator, page_count); lvgl_page_indicator_set_page(pageIndicator, page); } - if (prevButton != nullptr) { - lv_obj_set_hidden(prevButton, page_count <= 1); - lv_obj_set_hidden(nextButton, page_count <= 1); - } lv_obj_t* focusedTile = nullptr; const uint32_t first = page * layout.pageSize; diff --git a/Tactility/Source/app/alertdialog/AlertDialog.cpp b/Tactility/Source/app/alertdialog/AlertDialog.cpp index b27d2bdb3..6473d7b4b 100644 --- a/Tactility/Source/app/alertdialog/AlertDialog.cpp +++ b/Tactility/Source/app/alertdialog/AlertDialog.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include @@ -176,9 +177,9 @@ extern const ::AppManifest manifest = { .id = "tactility.alertdialog", .name = "Alert Dialog", .category = APP_CATEGORY_SYSTEM, - .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, .flags = APP_MANIFEST_FLAG_HIDDEN, - .stack = {} + .stack = { .depth = 3072, .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL }, }; } diff --git a/Tactility/Source/app/files/SupportedFiles.cpp b/Tactility/Source/app/files/SupportedFiles.cpp index 24031b696..a5887e462 100644 --- a/Tactility/Source/app/files/SupportedFiles.cpp +++ b/Tactility/Source/app/files/SupportedFiles.cpp @@ -27,6 +27,8 @@ constexpr auto text_file_extensions = std::to_array({ ".html", ".htm", ".css", ".scss", ".sass", ".less", ".xml", ".svg", + + ".codepoints", }); bool isSupportedAppFile(const std::string& filename) { diff --git a/Tactility/Source/app/inputdialog/InputDialog.cpp b/Tactility/Source/app/inputdialog/InputDialog.cpp index e26d26cfb..5474e0216 100644 --- a/Tactility/Source/app/inputdialog/InputDialog.cpp +++ b/Tactility/Source/app/inputdialog/InputDialog.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include @@ -38,6 +39,8 @@ struct Context { // emitting APP_EVENT_CLOSE) and this dialog's own thread (reader, after waking from it). int32_t resultCode = 1; // Cancelled - safety-net default if closed without pressing a button std::string resultText = {}; + /** Set by the first button or key that finishes the dialog, so later ones can't change the result */ + bool finished = false; }; struct ButtonContext { @@ -50,17 +53,35 @@ void onButtonDeleted(lv_event_t* e) { delete static_cast(lv_event_get_user_data(e)); } +void confirm(Context* ctx, lv_obj_t* textarea) { + if (ctx->finished) { + return; + } + ctx->finished = true; + LOG_I(TAG, "OK pressed"); + ctx->resultText = lv_textarea_get_text(textarea); + ctx->resultCode = 0; + app_event_emit_close(ctx->appInstanceId); +} + +/** The Enter key in the text field acts as the first button */ +void onTextareaReady(lv_event_t* e) { + confirm(static_cast(lv_event_get_user_data(e)), lv_event_get_target_obj(e)); +} + void onButtonPressed(lv_event_t* e) { auto* btnCtx = static_cast(lv_event_get_user_data(e)); if (btnCtx->textarea != nullptr) { - LOG_I(TAG, "OK pressed"); - btnCtx->ctx->resultText = lv_textarea_get_text(btnCtx->textarea); - btnCtx->ctx->resultCode = 0; + confirm(btnCtx->ctx, btnCtx->textarea); } else { + if (btnCtx->ctx->finished) { + return; + } + btnCtx->ctx->finished = true; LOG_I(TAG, "Cancel pressed"); btnCtx->ctx->resultCode = 1; + app_event_emit_close(btnCtx->ctx->appInstanceId); } - app_event_emit_close(btnCtx->ctx->appInstanceId); } void createButton(Context* ctx, lv_obj_t* parent, const std::string& text, lv_obj_t* textarea) { @@ -93,6 +114,7 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_set_width(textarea, LV_PCT(80)); lv_obj_align_to(textarea, message_label, LV_ALIGN_OUT_BOTTOM_MID, 0, 4); lv_textarea_set_one_line(textarea, true); + lv_obj_add_event_cb(textarea, onTextareaReady, LV_EVENT_READY, ctx); if (argv[2][0] != '\0') { lv_textarea_set_text(textarea, argv[2]); } @@ -177,9 +199,9 @@ extern const ::AppManifest manifest = { .id = "tactility.inputdialog", .name = "Input Dialog", .category = APP_CATEGORY_SYSTEM, - .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, .flags = APP_MANIFEST_FLAG_HIDDEN, - .stack = {} + .stack = { .depth = 3072, .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL }, }; } diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp index c38bba5cf..a4d35bbb2 100644 --- a/Tactility/Source/app/launcher/AppsMode.cpp +++ b/Tactility/Source/app/launcher/AppsMode.cpp @@ -31,7 +31,8 @@ constexpr IconEntry ICONS[] = { {"tactility.chat", LVGL_ICON_SHARED_FORUM}, {"tactility.files", LVGL_ICON_SHARED_FOLDER}, {"tactility.i2cscanner", LVGL_ICON_SHARED_CABLE}, - {"tactility.meshinspect", LVGL_ICON_SHARED_LISTS}, + {"tactility.meshchannels", LVGL_ICON_SHARED_FORUM}, + {"tactility.meshcontacts", LVGL_ICON_SHARED_FORUM}, {"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE}, {"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES}, {"tactility.terminal", LVGL_ICON_SHARED_TERMINAL}, diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index 33db7c60a..ea4bee389 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -6,13 +7,13 @@ #include #include -#include #include #include #include #include +#include #include #include @@ -206,7 +207,7 @@ extern const ::AppManifest manifest = { .category = APP_CATEGORY_SYSTEM, .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, .flags = APP_MANIFEST_FLAG_HIDDEN, - .stack = { .depth = 5120, .desired_memory_capability = 0 } + .stack = { .depth = 5120, .desired_memory_capability = 0 }, }; } // namespace diff --git a/Tactility/Source/app/meshchat/MeshAddContact.cpp b/Tactility/Source/app/meshchat/MeshAddContact.cpp new file mode 100644 index 000000000..01ea607da --- /dev/null +++ b/Tactility/Source/app/meshchat/MeshAddContact.cpp @@ -0,0 +1,279 @@ + +#include +#include + +#include +#include +#include +#include + +#include + +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace tt::app::meshchat { + +extern const ::AppManifest addContactManifest; + +namespace { + +constexpr auto* TAG = "MeshAddContact"; +constexpr size_t CONTACT_LIMIT = 64; +constexpr size_t NODE_LIMIT = 128; + +struct Context { + uint32_t appInstanceId; + TaskEventGroup* eventGroup = nullptr; + uint32_t addBit = 0; + uint32_t detailsBit = 0; + uint32_t dialogId = 0; + /** Written by the LVGL thread before it signals addBit or detailsBit */ + ::std::atomic selectedIndex = 0; + /** The nodes that aren't contacts yet */ + tt::std::vector candidates; + // Valid while the window is shown + lv_obj_t* list = nullptr; +}; + +::std::string getNodeName(const MeshNode& node) { + return node.name[0] != '\0' ? ::std::string(node.name) : addressToHex(node.address); +} + +/** Queries the service thread, so it's called without the LVGL lock */ +void loadCandidates(Context* ctx) { + tt::std::vector contacts(CONTACT_LIMIT); + size_t contactCount = 0; + if (mesh_get_contacts(contacts.data(), CONTACT_LIMIT, &contactCount) != ERROR_NONE) { + contactCount = 0; + } + + ctx->candidates.assign(NODE_LIMIT, MeshNode {}); + size_t count = 0; + if (mesh_get_nodes(ctx->candidates.data(), NODE_LIMIT, &count) != ERROR_NONE) { + count = 0; + } + ctx->candidates.resize(count); + for (size_t i = ctx->candidates.size(); i > 0; i--) { + const auto& node = ctx->candidates[i - 1]; + for (size_t c = 0; c < contactCount; c++) { + if (contacts[c].address.length == node.address.length && memcmp(contacts[c].address.bytes, node.address.bytes, node.address.length) == 0) { + ctx->candidates.erase(ctx->candidates.begin() + (i - 1)); + break; + } + } + } +} + +void onSelected(lv_event_t* event, uint32_t Context::*bit) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->selectedIndex = (int32_t)reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event))); + task_event_group_signal(ctx->eventGroup, ctx->*bit); +} + +// Short click, so that the end of a long press doesn't add the contact +void onNodeClicked(lv_event_t* event) { + onSelected(event, &Context::addBit); +} + +void onNodeLongPressed(lv_event_t* event) { + onSelected(event, &Context::detailsBit); +} + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +/** Requires the LVGL lock */ +void updateWidgets(Context* ctx) { + if (ctx->list == nullptr) { + return; + } + lv_obj_clean(ctx->list); + + auto* card = lvgl_card_create(ctx->list); + lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT); + // The list items' pressed and focused backgrounds follow the card's rounded corners + lv_obj_set_style_clip_corner(card, true, LV_STATE_DEFAULT); + + // The card provides the background + auto* list = lvgl_list_create(card); + lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(list, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT); + + if (ctx->candidates.empty()) { + lvgl_list_add_text(list, "No nodes heard yet. Nodes that you hear on the mesh are listed here."); + return; + } + for (size_t i = 0; i < ctx->candidates.size(); i++) { + const auto& node = ctx->candidates[i]; + auto* button = lvgl_list_add_button(list, nullptr, getNodeName(node).c_str()); + lv_obj_set_flex_flow(button, LV_FLEX_FLOW_COLUMN); + lv_obj_set_user_data(button, reinterpret_cast(static_cast(i))); + lv_obj_add_event_cb(button, onNodeClicked, LV_EVENT_SHORT_CLICKED, ctx); + lv_obj_add_event_cb(button, onNodeLongPressed, LV_EVENT_LONG_PRESSED, ctx); + + auto* name = lv_obj_get_child(button, lv_obj_get_child_count(button) - 1); + // The button stacks the name and the hash, so the name doesn't grow along the row + lv_obj_set_flex_grow(name, 0); + lv_obj_set_width(name, LV_PCT(100)); + lv_label_set_long_mode(name, LV_LABEL_LONG_MODE_DOTS); + + // The remaining data is cut off with an ellipsis, and shown completely on a long press + auto* hash = lv_label_create(button); + lv_obj_set_width(hash, LV_PCT(100)); + lv_obj_set_style_text_font(hash, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT); + lv_label_set_long_mode(hash, LV_LABEL_LONG_MODE_DOTS); + lv_label_set_text(hash, addressToHex(node.address).c_str()); + } +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, "Add contact"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + + ctx->list = lv_obj_create(parent); + lv_obj_set_width(ctx->list, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->list, 1); + lv_obj_set_flex_flow(ctx->list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(ctx->list, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(ctx->list, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->list, 0, LV_STATE_DEFAULT); + + updateWidgets(ctx); +} + +void destroyWidgets(void* userData) { + static_cast(userData)->list = nullptr; +} + +/** @return true when the contact was added */ +bool addSelected(Context* ctx) { + const auto index = (size_t)ctx->selectedIndex.load(); + if (index >= ctx->candidates.size()) { + return false; + } + const auto& node = ctx->candidates[index]; + const error_t error = mesh_add_contact(&node.address, node.name); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to add contact: %s", error_to_string(error)); + ctx->dialogId = alertdialog::start( + ctx->appInstanceId, + "Couldn't add contact", + error == ERROR_RESOURCE ? "There are too many contacts." : error_to_string(error) + ); + return false; + } + return true; +} + +void showDetails(Context* ctx) { + const auto index = (size_t)ctx->selectedIndex.load(); + if (ctx->dialogId == 0 && index < ctx->candidates.size()) { + const auto& node = ctx->candidates[index]; + ctx->dialogId = alertdialog::start(ctx->appInstanceId, getNodeName(node), addressToHex(node.address)); + } +} + +int32_t appMain(int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = app_scheduler_current_app_id(); + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + ctx.eventGroup = &eventGroup; + check(task_event_group_claim_bit(&eventGroup, &ctx.addBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.detailsBit) == ERROR_NONE); + + AppEventSubscription appEvents {}; + check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); + MeshEventSubscription meshEvents {}; + check(mesh_event_subscribe(&meshEvents, &eventGroup) == ERROR_NONE); + + loadCandidates(&ctx); + WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + uint32_t flags = 0; + task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY); + + if (flags & ctx.addBit) { + shouldClose |= addSelected(&ctx); + } + if (flags & ctx.detailsBit) { + showDetails(&ctx); + } + + // Nodes that are heard while the list is empty show up without reopening it + bool changed = false; + MeshEvent meshEvent {}; + while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { + changed |= meshEvent.type == MESH_EVENT_NODE && ctx.candidates.empty(); + } + + AppEvent event {}; + while (app_event_poll(&appEvents, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + if (event.type == APP_EVENT_RESULT) { + if (event.result.launch_id == ctx.dialogId) { + ctx.dialogId = 0; + } + app_manager_stop(event.result.launch_id); + } + } + + if (changed && !shouldClose) { + loadCandidates(&ctx); + lvgl_lock(); + updateWidgets(&ctx); + lvgl_unlock(); + } + } + + window_manager_remove(window); + check(mesh_event_unsubscribe(&meshEvents) == ERROR_NONE); + check(app_event_unsubscribe(&appEvents) == ERROR_NONE); + task_event_group_destruct(&eventGroup); + return 0; +} + +} // namespace + +extern const ::AppManifest addContactManifest = { + .id = ADD_CONTACT_APP_ID, + .name = "Add Contact", + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, + .stack = {} +}; + +} // namespace tt::app::meshchat + diff --git a/Tactility/Source/app/meshchat/MeshChannels.cpp b/Tactility/Source/app/meshchat/MeshChannels.cpp new file mode 100644 index 000000000..ea2580915 --- /dev/null +++ b/Tactility/Source/app/meshchat/MeshChannels.cpp @@ -0,0 +1,430 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include + +#include + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace tt::app::meshchat { + +extern const ::AppManifest channelsManifest; + +namespace { + +constexpr auto* TAG = "MeshChannels"; +constexpr size_t CHANNEL_LIMIT = 8; + +struct Context { + uint32_t appInstanceId; + TaskEventGroup* eventGroup = nullptr; + uint32_t openBit = 0; + uint32_t settingsBit = 0; + uint32_t addChannelBit = 0; + /** Written by the LVGL thread before it signals openBit */ + ::std::atomic openIndex = 0; + uint32_t addTypeDialogId = 0; + uint32_t joinChannelDialogId = 0; + uint32_t createChannelDialogId = 0; + uint32_t privateNameDialogId = 0; + uint32_t privateKeyDialogId = 0; + uint32_t messageDialogId = 0; + AppStream channelStream {}; + char channelBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + AppStream createStream {}; + char createBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + AppStream privateNameStream {}; + char privateNameBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + AppStream privateKeyStream {}; + char privateKeyBuffer[MESH_CHANNEL_KEY_MAX_LENGTH * 2 + 1] = {}; + /** The name that was entered while the key is asked */ + ::std::string pendingChannelName; + tt::std::vector channels; + // Valid while the window is shown + lv_obj_t* statusLabel = nullptr; + lv_obj_t* content = nullptr; +}; + +bool isMeshOn() { + return mesh_get_state() == MESH_STATE_ON; +} + +::std::string getStatusText() { + return ::std::string("Mesh: ") + mesh_state_to_string(mesh_get_state()); +} + +/** Queries the service thread, so it's called without the LVGL lock */ +void loadData(Context* ctx) { + ctx->channels.assign(CHANNEL_LIMIT, MeshChannel {}); + size_t count = 0; + if (mesh_get_channels(ctx->channels.data(), CHANNEL_LIMIT, &count) != ERROR_NONE) { + count = 0; + } + ctx->channels.resize(count); +} + +lv_obj_t* addListButton(lv_obj_t* list, const char* text, lv_event_cb_t callback, void* callbackData, intptr_t index) { + auto* button = lvgl_list_add_button(list, nullptr, text); + lv_obj_set_user_data(button, reinterpret_cast(index)); + lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, callbackData); + return button; +} + +void onChannelPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->openIndex = (int32_t)reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event))); + task_event_group_signal(ctx->eventGroup, ctx->openBit); +} + +void onAddChannelPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->addChannelBit); +} + +void onSettingsPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->settingsBit); +} + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +/** Requires the LVGL lock */ +void updateWidgets(Context* ctx) { + if (ctx->content == nullptr) { + return; + } + lv_label_set_text(ctx->statusLabel, getStatusText().c_str()); + lv_obj_set_hidden(ctx->statusLabel, isMeshOn()); + + lv_obj_clean(ctx->content); + auto* channelList = createCard(ctx->content); + for (size_t i = 0; i < ctx->channels.size(); i++) { + addListButton(channelList, ctx->channels[i].name, onChannelPressed, ctx, (intptr_t)i); + } +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, "Mesh Channels"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddChannelPressed, ctx); + lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsPressed, ctx); + + ctx->statusLabel = lv_label_create(parent); + lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); + lv_obj_set_style_pad_hor(ctx->statusLabel, LV_DPX(8), LV_STATE_DEFAULT); + + ctx->content = createContent(parent); + + updateWidgets(ctx); +} + +void destroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + ctx->statusLabel = nullptr; + ctx->content = nullptr; +} + +void startConversation(Context* ctx) { + const auto index = (size_t)ctx->openIndex.load(); + if (index < ctx->channels.size()) { + const ::std::string channelIndex = ::std::to_string(ctx->channels[index].index); + const char* argv[] = { KIND_CHANNEL, channelIndex.c_str(), ctx->channels[index].name }; + startApp(CONVERSATION_APP_ID, 3, argv); + } +} + +void showMessage(Context* ctx, const char* title, const char* message) { + if (ctx->messageDialogId == 0) { + ctx->messageDialogId = alertdialog::start(ctx->appInstanceId, title, message); + } +} + +/** The order of these matches the options of the selection dialog */ +enum class AddType { JoinPublic, JoinPrivate, CreatePrivate }; + +void startAddChannel(Context* ctx) { + if (ctx->addTypeDialogId != 0) { + return; + } + ctx->addTypeDialogId = selectiondialog::start(ctx->appInstanceId, "Add channel", { + "Join public channel", + "Join private channel", + "Create private channel" + }); +} + +void startJoinChannel(Context* ctx); +void startJoinPrivateChannel(Context* ctx); +void startCreateChannel(Context* ctx); + +void onAddTypeSelected(Context* ctx, int32_t selected) { + switch (static_cast(selected)) { + case AddType::JoinPublic: + startJoinChannel(ctx); + break; + case AddType::JoinPrivate: + startJoinPrivateChannel(ctx); + break; + case AddType::CreatePrivate: + startCreateChannel(ctx); + break; + } +} + +void startJoinChannel(Context* ctx) { + if (ctx->joinChannelDialogId != 0) { + return; + } + ctx->joinChannelDialogId = inputdialog::start( + ctx->appInstanceId, + "Join channel", + "Hashtag channel name, e.g. #test", + "#", + ctx->channelStream, + ctx->channelBuffer, + sizeof(ctx->channelBuffer), + ctx->eventGroup + ); +} + +void onJoinChannelResult(Context* ctx, int32_t result) { + if (result == 0) { + char name[sizeof(Context::channelBuffer)] = {}; + const size_t length = app_stream_read(&ctx->channelStream, name, sizeof(name) - 1); + name[length] = '\0'; + const error_t error = meshsettings::joinHashtagChannel(name); + if (error == ERROR_INVALID_ARGUMENT) { + showMessage(ctx, "Couldn't join channel", "Use a channel name that starts with #. This mesh network can't join a channel by name."); + } else if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to join channel %s: %s", name, error_to_string(error)); + showMessage(ctx, "Couldn't join channel", error == ERROR_INVALID_STATE ? getNotReadyText().c_str() : error_to_string(error)); + } + } + app_stream_unsubscribe(&ctx->channelStream); +} + +void startCreateChannel(Context* ctx) { + if (ctx->createChannelDialogId != 0) { + return; + } + ctx->createChannelDialogId = inputdialog::start( + ctx->appInstanceId, + "Create private channel", + "Name of the channel", + "", + ctx->createStream, + ctx->createBuffer, + sizeof(ctx->createBuffer), + ctx->eventGroup + ); +} + +void onCreateChannelResult(Context* ctx, int32_t result) { + if (result == 0) { + char name[sizeof(Context::createBuffer)] = {}; + const size_t length = app_stream_read(&ctx->createStream, name, sizeof(name) - 1); + name[length] = '\0'; + const error_t error = meshsettings::createPrivateChannel(name); + if (error == ERROR_INVALID_ARGUMENT) { + showMessage(ctx, "Couldn't create channel", "Enter a name for the channel."); + } else if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to create channel %s: %s", name, error_to_string(error)); + showMessage(ctx, "Couldn't create channel", error == ERROR_INVALID_STATE ? getNotReadyText().c_str() : error_to_string(error)); + } + } + app_stream_unsubscribe(&ctx->createStream); +} + +void startJoinPrivateChannel(Context* ctx) { + if (ctx->privateNameDialogId != 0 || ctx->privateKeyDialogId != 0) { + return; + } + ctx->privateNameDialogId = inputdialog::start( + ctx->appInstanceId, + "Join private channel", + "Name of the channel", + "", + ctx->privateNameStream, + ctx->privateNameBuffer, + sizeof(ctx->privateNameBuffer), + ctx->eventGroup + ); +} + +void onPrivateNameResult(Context* ctx, int32_t result) { + if (result == 0) { + char name[sizeof(Context::privateNameBuffer)] = {}; + const size_t length = app_stream_read(&ctx->privateNameStream, name, sizeof(name) - 1); + name[length] = '\0'; + ctx->pendingChannelName = name; + } + app_stream_unsubscribe(&ctx->privateNameStream); + if (result != 0 || ctx->pendingChannelName.empty()) { + ctx->pendingChannelName.clear(); + return; + } + ctx->privateKeyDialogId = inputdialog::start( + ctx->appInstanceId, + "Channel key", + "Key of the channel in hexadecimal", + "", + ctx->privateKeyStream, + ctx->privateKeyBuffer, + sizeof(ctx->privateKeyBuffer), + ctx->eventGroup + ); +} + +void onPrivateKeyResult(Context* ctx, int32_t result) { + if (result == 0) { + char key[sizeof(Context::privateKeyBuffer)] = {}; + const size_t length = app_stream_read(&ctx->privateKeyStream, key, sizeof(key) - 1); + key[length] = '\0'; + MeshAddress bytes; + if (!hexToAddress(key, &bytes)) { + showMessage(ctx, "Invalid key", "Enter the key as hexadecimal characters, two for every byte."); + } else { + const error_t error = meshsettings::joinPrivateChannel(ctx->pendingChannelName.c_str(), bytes.bytes, bytes.length); + if (error == ERROR_INVALID_ARGUMENT) { + showMessage(ctx, "Invalid key", "This mesh network expects a key of a different length."); + } else if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to join channel %s: %s", ctx->pendingChannelName.c_str(), error_to_string(error)); + showMessage(ctx, "Couldn't join channel", error == ERROR_INVALID_STATE ? getNotReadyText().c_str() : error_to_string(error)); + } + } + } + app_stream_unsubscribe(&ctx->privateKeyStream); + ctx->pendingChannelName.clear(); +} + +int32_t appMain(int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = app_scheduler_current_app_id(); + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + ctx.eventGroup = &eventGroup; + check(task_event_group_claim_bit(&eventGroup, &ctx.openBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.settingsBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.addChannelBit) == ERROR_NONE); + + AppEventSubscription appEvents {}; + check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); + MeshEventSubscription meshEvents {}; + check(mesh_event_subscribe(&meshEvents, &eventGroup) == ERROR_NONE); + + loadData(&ctx); + WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + uint32_t flags = 0; + task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY); + + if (flags & ctx.openBit) { + startConversation(&ctx); + } + if (flags & ctx.settingsBit) { + startApp(MESH_SETTINGS_APP_ID, 0, nullptr); + } + if (flags & ctx.addChannelBit) { + startAddChannel(&ctx); + } + + bool changed = false; + MeshEvent meshEvent {}; + while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { + changed |= meshEvent.type == MESH_EVENT_STATE || meshEvent.type == MESH_EVENT_CHANNEL; + } + + AppEvent event {}; + while (app_event_poll(&appEvents, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + if (event.type == APP_EVENT_RESULT) { + if (event.result.launch_id == ctx.addTypeDialogId) { + ctx.addTypeDialogId = 0; + if (event.result.result >= 0) { + onAddTypeSelected(&ctx, event.result.result); + } + } else if (event.result.launch_id == ctx.privateNameDialogId) { + ctx.privateNameDialogId = 0; + onPrivateNameResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.privateKeyDialogId) { + ctx.privateKeyDialogId = 0; + onPrivateKeyResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.createChannelDialogId) { + ctx.createChannelDialogId = 0; + onCreateChannelResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.joinChannelDialogId) { + ctx.joinChannelDialogId = 0; + onJoinChannelResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.messageDialogId) { + ctx.messageDialogId = 0; + } + app_manager_stop(event.result.launch_id); + // The channels and contacts may have changed, and the window may have been rebuilt + changed = true; + } + } + + if (changed) { + loadData(&ctx); + lvgl_lock(); + updateWidgets(&ctx); + lvgl_unlock(); + } + } + + window_manager_remove(window); + check(mesh_event_unsubscribe(&meshEvents) == ERROR_NONE); + check(app_event_unsubscribe(&appEvents) == ERROR_NONE); + task_event_group_destruct(&eventGroup); + return 0; +} + +} // namespace + +extern const ::AppManifest channelsManifest = { + .id = "tactility.meshchannels", + .name = "Mesh Channels", + .category = APP_CATEGORY_USER, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, + .flags = 0, + .stack = { .depth = 6144, .desired_memory_capability = 0 }, +}; + +} // namespace tt::app::meshchat + diff --git a/Tactility/Source/app/meshchat/MeshChatCommon.cpp b/Tactility/Source/app/meshchat/MeshChatCommon.cpp new file mode 100644 index 000000000..34c623a01 --- /dev/null +++ b/Tactility/Source/app/meshchat/MeshChatCommon.cpp @@ -0,0 +1,52 @@ +#include + +#include + +#include + +#include + +namespace tt::app::meshchat { + +constexpr auto* TAG = "MeshChat"; + +void startApp(const char* id, int argc, const char* const argv[]) { + AppStartContext context; + if (app_start_context_from_id(id, &context) != ERROR_NONE) { + LOG_E(TAG, "Failed to find %s", id); + return; + } + app_start_context_set_arguments_ext(&context, argc, argv); + AppInstanceId instanceId = 0; + if (app_start_with_context(&context, &instanceId) != ERROR_NONE) { + LOG_E(TAG, "Failed to start %s", id); + } +} + +lv_obj_t* createContent(lv_obj_t* parent) { + auto* content = lv_obj_create(parent); + // The theme's container padding is the margin of the cards, read before it is overridden below + lv_obj_set_user_data(content, reinterpret_cast(static_cast(lv_obj_get_style_pad_left(content, LV_PART_MAIN)))); + lv_obj_set_width(content, LV_PCT(100)); + lv_obj_set_flex_grow(content, 1); + lv_obj_set_flex_flow(content, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(content, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(content, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_border_width(content, 0, LV_STATE_DEFAULT); + return content; +} + +lv_obj_t* createCard(lv_obj_t* parent) { + auto* card = lvgl_card_create(parent); + // A percentage width ignores the margin, so the width is the available width without the margins + const auto margin = static_cast(reinterpret_cast(lv_obj_get_user_data(parent))); + lv_obj_update_layout(parent); + lv_obj_set_size(card, lv_obj_get_content_width(parent) - 2 * margin, LV_SIZE_CONTENT); + lv_obj_set_style_margin_all(card, margin, LV_STATE_DEFAULT); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(card, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(card, 0, LV_STATE_DEFAULT); + return card; +} + +} diff --git a/Tactility/Source/app/meshchat/MeshContacts.cpp b/Tactility/Source/app/meshchat/MeshContacts.cpp new file mode 100644 index 000000000..fd33f2a54 --- /dev/null +++ b/Tactility/Source/app/meshchat/MeshContacts.cpp @@ -0,0 +1,368 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace tt::app::meshchat { + +extern const ::AppManifest contactsManifest; + +namespace { + +constexpr auto* TAG = "MeshContacts"; +constexpr size_t CONTACT_LIMIT = 64; + +struct Context { + uint32_t appInstanceId; + TaskEventGroup* eventGroup = nullptr; + uint32_t openBit = 0; + uint32_t menuBit = 0; + /** Written by the LVGL thread before it signals openBit */ + ::std::atomic openIndex = 0; + uint32_t menuDialogId = 0; + uint32_t contactNameDialogId = 0; + uint32_t contactKeyDialogId = 0; + uint32_t messageDialogId = 0; + AppStream contactNameStream {}; + char contactNameBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + AppStream contactKeyStream {}; + char contactKeyBuffer[MESH_ADDRESS_MAX_LENGTH * 2 + 1] = {}; + /** The name that was entered while the key is asked */ + ::std::string pendingContactName; + tt::std::vector contacts; + // Valid while the window is shown + lv_obj_t* statusLabel = nullptr; + lv_obj_t* content = nullptr; +}; + +bool isMeshOn() { + return mesh_get_state() == MESH_STATE_ON; +} + +::std::string getStatusText() { + return ::std::string("Mesh: ") + mesh_state_to_string(mesh_get_state()); +} + +/** Queries the service thread, so it's called without the LVGL lock */ +void loadData(Context* ctx) { + ctx->contacts.assign(CONTACT_LIMIT, MeshContact {}); + size_t count = 0; + if (mesh_get_contacts(ctx->contacts.data(), CONTACT_LIMIT, &count) != ERROR_NONE) { + count = 0; + } + ctx->contacts.resize(count); +} + +lv_obj_t* addListButton(lv_obj_t* list, const char* text, lv_event_cb_t callback, void* callbackData, intptr_t index) { + auto* button = lvgl_list_add_button(list, nullptr, text); + lv_obj_set_user_data(button, reinterpret_cast(index)); + lv_obj_add_event_cb(button, callback, LV_EVENT_SHORT_CLICKED, callbackData); + return button; +} + +void onContactPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + auto* button = lv_event_get_target_obj(event); + ctx->openIndex = (int32_t)reinterpret_cast(lv_obj_get_user_data(button)); + task_event_group_signal(ctx->eventGroup, ctx->openBit); +} + +void onMenuPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->menuBit); +} + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +/** Requires the LVGL lock */ +void updateWidgets(Context* ctx) { + if (ctx->content == nullptr) { + return; + } + lv_label_set_text(ctx->statusLabel, getStatusText().c_str()); + lv_obj_set_hidden(ctx->statusLabel, isMeshOn()); + + lv_obj_clean(ctx->content); + auto* contactList = createCard(ctx->content); + for (size_t i = 0; i < ctx->contacts.size(); i++) { + addListButton(contactList, ctx->contacts[i].name, onContactPressed, ctx, (intptr_t)i); + } + if (ctx->contacts.empty()) { + lvgl_list_add_text(contactList, "No contacts yet. Add a node that you've heard, or wait for a direct message."); + } +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, "Mesh Contacts"); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + auto* menuButton = lvgl_toolbar_add_text_button_action(toolbar, LVGL_ICON_SHARED_MORE_VERT, onMenuPressed, ctx); + lv_obj_set_style_text_font(menuButton, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); + + ctx->statusLabel = lv_label_create(parent); + lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); + lv_obj_set_style_pad_hor(ctx->statusLabel, LV_DPX(8), LV_STATE_DEFAULT); + + ctx->content = createContent(parent); + + updateWidgets(ctx); +} + +void destroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + ctx->statusLabel = nullptr; + ctx->content = nullptr; +} + +void startConversation(Context* ctx) { + const auto index = (size_t)ctx->openIndex.load(); + if (index < ctx->contacts.size()) { + const ::std::string address = addressToHex(ctx->contacts[index].address); + const char* argv[] = { KIND_CONTACT, address.c_str(), ctx->contacts[index].name }; + startApp(CONVERSATION_APP_ID, 3, argv); + } +} + +void showMessage(Context* ctx, const char* title, const char* message) { + if (ctx->messageDialogId == 0) { + ctx->messageDialogId = alertdialog::start(ctx->appInstanceId, title, message); + } +} + +void startAddContact() { + startApp(ADD_CONTACT_APP_ID, 0, nullptr); +} + +/** Shows this node as a QR code that other mesh apps can scan */ +void showOwnQr(Context* ctx) { + char uri[256]; + const error_t error = mesh_get_contact_uri(nullptr, uri, sizeof(uri)); + if (error == ERROR_NONE) { + qrcode::start(uri); + } else if (error == ERROR_NOT_SUPPORTED) { + showMessage(ctx, "Not available", "This mesh network can't share a contact as a QR code."); + } else { + LOG_E(TAG, "Failed to get the contact: %s", error_to_string(error)); + showMessage(ctx, "Couldn't create QR code", error == ERROR_INVALID_STATE ? getNotReadyText().c_str() : error_to_string(error)); + } +} + +void startContactNameDialog(Context* ctx) { + if (ctx->contactNameDialogId != 0 || ctx->contactKeyDialogId != 0) { + return; + } + ctx->contactNameDialogId = inputdialog::start( + ctx->appInstanceId, + "Add contact", + "Name of the contact", + "", + ctx->contactNameStream, + ctx->contactNameBuffer, + sizeof(ctx->contactNameBuffer), + ctx->eventGroup + ); +} + +void onContactNameResult(Context* ctx, int32_t result) { + if (result == 0) { + char name[sizeof(Context::contactNameBuffer)] = {}; + const size_t length = app_stream_read(&ctx->contactNameStream, name, sizeof(name) - 1); + name[length] = '\0'; + ctx->pendingContactName = name; + } + app_stream_unsubscribe(&ctx->contactNameStream); + if (result != 0 || ctx->pendingContactName.empty()) { + ctx->pendingContactName.clear(); + return; + } + ctx->contactKeyDialogId = inputdialog::start( + ctx->appInstanceId, + "Public key", + "Public key of the contact in hexadecimal", + "", + ctx->contactKeyStream, + ctx->contactKeyBuffer, + sizeof(ctx->contactKeyBuffer), + ctx->eventGroup + ); +} + +void onContactKeyResult(Context* ctx, int32_t result) { + if (result == 0) { + char key[sizeof(Context::contactKeyBuffer)] = {}; + const size_t length = app_stream_read(&ctx->contactKeyStream, key, sizeof(key) - 1); + key[length] = '\0'; + MeshAddress address; + if (!hexToAddress(key, &address)) { + showMessage(ctx, "Invalid key", "Enter the public key as hexadecimal characters, two for every byte."); + } else { + const error_t error = mesh_add_contact(&address, ctx->pendingContactName.c_str()); + if (error == ERROR_INVALID_ARGUMENT) { + showMessage(ctx, "Invalid key", "This mesh network expects a public key of a different length."); + } else if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to add contact: %s", error_to_string(error)); + showMessage(ctx, "Couldn't add contact", error == ERROR_RESOURCE ? "There are too many contacts." : error_to_string(error)); + } + } + } + app_stream_unsubscribe(&ctx->contactKeyStream); + ctx->pendingContactName.clear(); +} + +/** The order of these matches the options of the selection dialog */ +enum class MenuItem { AddByKey, AddSeen, ShowQr, Settings }; + +void startMenu(Context* ctx) { + if (ctx->menuDialogId != 0) { + return; + } + ctx->menuDialogId = selectiondialog::start(ctx->appInstanceId, "Mesh Contacts", { + "Add by key", + "Add seen", + "Show my QR", + "Settings" + }); +} + +void onMenuSelected(Context* ctx, int32_t selected) { + switch (static_cast(selected)) { + case MenuItem::AddByKey: + startContactNameDialog(ctx); + break; + case MenuItem::AddSeen: + startAddContact(); + break; + case MenuItem::ShowQr: + showOwnQr(ctx); + break; + case MenuItem::Settings: + startApp(MESH_SETTINGS_APP_ID, 0, nullptr); + break; + } +} + +int32_t appMain(int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = app_scheduler_current_app_id(); + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + ctx.eventGroup = &eventGroup; + check(task_event_group_claim_bit(&eventGroup, &ctx.openBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.menuBit) == ERROR_NONE); + + AppEventSubscription appEvents {}; + check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); + MeshEventSubscription meshEvents {}; + check(mesh_event_subscribe(&meshEvents, &eventGroup) == ERROR_NONE); + + loadData(&ctx); + WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + uint32_t flags = 0; + task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY); + + if (flags & ctx.openBit) { + startConversation(&ctx); + } + if (flags & ctx.menuBit) { + startMenu(&ctx); + } + + bool changed = false; + MeshEvent meshEvent {}; + while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { + changed |= meshEvent.type == MESH_EVENT_STATE || meshEvent.type == MESH_EVENT_CONTACT; + } + + AppEvent event {}; + while (app_event_poll(&appEvents, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + if (event.type == APP_EVENT_RESULT) { + if (event.result.launch_id == ctx.menuDialogId) { + ctx.menuDialogId = 0; + if (event.result.result >= 0) { + onMenuSelected(&ctx, event.result.result); + } + } else if (event.result.launch_id == ctx.contactNameDialogId) { + ctx.contactNameDialogId = 0; + onContactNameResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.contactKeyDialogId) { + ctx.contactKeyDialogId = 0; + onContactKeyResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.messageDialogId) { + ctx.messageDialogId = 0; + } + app_manager_stop(event.result.launch_id); + // The channels and contacts may have changed, and the window may have been rebuilt + changed = true; + } + } + + if (changed) { + loadData(&ctx); + lvgl_lock(); + updateWidgets(&ctx); + lvgl_unlock(); + } + } + + window_manager_remove(window); + check(mesh_event_unsubscribe(&meshEvents) == ERROR_NONE); + check(app_event_unsubscribe(&appEvents) == ERROR_NONE); + task_event_group_destruct(&eventGroup); + return 0; +} + +} // namespace + +extern const ::AppManifest contactsManifest = { + .id = "tactility.meshcontacts", + .name = "Mesh Contacts", + .category = APP_CATEGORY_USER, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, + .flags = 0, + .stack = { .depth = 6144, .desired_memory_capability = 0 }, +}; + +} // namespace tt::app::meshchat + diff --git a/Tactility/Source/app/meshchat/MeshConversation.cpp b/Tactility/Source/app/meshchat/MeshConversation.cpp new file mode 100644 index 000000000..bb580fe17 --- /dev/null +++ b/Tactility/Source/app/meshchat/MeshConversation.cpp @@ -0,0 +1,486 @@ + +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace tt::app::meshchat { + +extern const ::AppManifest conversationManifest; + +namespace { + +constexpr auto* TAG = "MeshConversation"; +constexpr size_t MESSAGE_LIMIT = 30; + +struct Context { + uint32_t appInstanceId; + TaskEventGroup* eventGroup = nullptr; + uint32_t removeBit = 0; + uint32_t infoBit = 0; + uint32_t removeDialogId = 0; + uint32_t messageDialogId = 0; + bool isContact = true; + /** A channel with a key that isn't derived from its name, so it has to be shared */ + bool isPrivateChannel = false; + MeshAddress peer {}; + uint8_t channel = 0; + ::std::string title; + /** Oldest first */ + tt::std::deque messages; + // Valid while the window is shown + lv_obj_t* statusLabel = nullptr; + lv_obj_t* list = nullptr; + lv_obj_t* input = nullptr; +}; + +const MeshAddress* getPeer(const Context* ctx) { + return ctx->isContact ? &ctx->peer : nullptr; +} + +/** How far a message got. Channel messages are never acknowledged by their recipients. */ +const char* getReceiptText(MeshDeliveryState delivery) { + switch (delivery) { + case MESH_DELIVERY_QUEUED: + return "Sending..."; + case MESH_DELIVERY_BLOCKED: + return "Waiting"; + case MESH_DELIVERY_SENT: + return "Sent"; + case MESH_DELIVERY_DELIVERED: + return LV_SYMBOL_OK " Delivered"; + case MESH_DELIVERY_FAILED: + return LV_SYMBOL_CLOSE " Not delivered"; + case MESH_DELIVERY_CANCELLED: + return "Cancelled"; + default: + return ""; + } +} + +::std::string getHeaderText(const MeshMessage& message) { + ::std::string header = message.outgoing ? "You" : (message.sender_name[0] != '\0' ? message.sender_name : "?"); + if (message.wall_time > 0) { + const time_t time = (time_t)message.wall_time; + struct tm local {}; + char text[8]; + if (localtime_r(&time, &local) != nullptr && strftime(text, sizeof(text), "%H:%M", &local) > 0) { + header += " "; + header += text; + } + } + if (message.outgoing) { + header += " "; + header += getReceiptText(message.delivery); + } + return header; +} + +bool belongsToConversation(const Context* ctx, const MeshMessage& message) { + char backend[MESH_BACKEND_ID_MAX_LENGTH + 1]; + if (mesh_get_backend(backend, sizeof(backend)) != ERROR_NONE || strncmp(message.backend, backend, MESH_BACKEND_ID_MAX_LENGTH) != 0) { + return false; + } + if (ctx->isContact) { + return message.peer.length == ctx->peer.length && memcmp(message.peer.bytes, ctx->peer.bytes, ctx->peer.length) == 0; + } + return message.peer.length == 0 && message.channel == ctx->channel; +} + +/** Requires the LVGL lock */ +void createMessageItem(Context* ctx, const MeshMessage& message) { + auto* item = lv_obj_create(ctx->list); + lv_obj_set_size(item, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(item, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_hor(item, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_pad_ver(item, LV_DPX(2), LV_STATE_DEFAULT); + lv_obj_set_style_pad_row(item, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(item, 0, LV_STATE_DEFAULT); + lv_obj_set_style_bg_opa(item, LV_OPA_TRANSP, LV_STATE_DEFAULT); + lv_obj_set_scrollable(item, false); + lv_obj_set_user_data(item, reinterpret_cast(static_cast(message.seq))); + const auto align = message.outgoing ? LV_TEXT_ALIGN_RIGHT : LV_TEXT_ALIGN_LEFT; + + auto* header = lv_label_create(item); + lv_obj_set_width(header, LV_PCT(100)); + lv_obj_set_style_text_align(header, align, LV_STATE_DEFAULT); + lv_obj_set_style_text_font(header, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT); + lv_label_set_text(header, getHeaderText(message).c_str()); + + auto* text = lv_label_create(item); + lv_obj_set_width(text, LV_PCT(100)); + lv_obj_set_style_text_align(text, align, LV_STATE_DEFAULT); + lv_label_set_long_mode(text, LV_LABEL_LONG_MODE_WRAP); + lv_label_set_text(text, message.text); +} + +/** Requires the LVGL lock */ +void scrollToNewest(Context* ctx) { + lv_obj_update_layout(ctx->list); + lv_obj_scroll_to_y(ctx->list, LV_COORD_MAX, LV_ANIM_OFF); +} + +/** Requires the LVGL lock */ +void updateStatus(Context* ctx) { + if (ctx->statusLabel == nullptr) { + return; + } + const bool on = mesh_get_state() == MESH_STATE_ON; + lv_label_set_text(ctx->statusLabel, (::std::string("Mesh: ") + mesh_state_to_string(mesh_get_state())).c_str()); + lv_obj_set_hidden(ctx->statusLabel, on); +} + +void onSendPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + const char* text = lv_textarea_get_text(ctx->input); + if (text[0] == '\0') { + return; + } + const error_t error = mesh_send_text(getPeer(ctx), ctx->channel, text, nullptr); + if (error == ERROR_NONE) { + lv_textarea_set_text(ctx->input, ""); + } else { + LOG_W(TAG, "Failed to send: %s", error_to_string(error)); + lv_label_set_text(ctx->statusLabel, error == ERROR_INVALID_STATE ? "Turn mesh on to send messages" : error_to_string(error)); + lv_obj_set_hidden(ctx->statusLabel, false); + } +} + +void onRemovePressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->removeBit); +} + +void onInfoPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->infoBit); +} + +void onBackPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +/** Adds a toolbar action that shows a shared icon, which the text font doesn't have */ +void addIconAction(lv_obj_t* toolbar, const char* icon, lv_event_cb_t callback, void* userData) { + auto* button = lvgl_toolbar_add_text_button_action(toolbar, icon, callback, userData); + lv_obj_set_style_text_font(button, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); + + auto* toolbar = lvgl_toolbar_create(parent, ctx->title.c_str()); + lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); + if (ctx->isPrivateChannel) { + addIconAction(toolbar, LVGL_ICON_SHARED_INFO, onInfoPressed, ctx); + } + // The primary channel can't be left + if (ctx->isContact || ctx->channel != 0) { + addIconAction(toolbar, LVGL_ICON_SHARED_PERSON_REMOVE, onRemovePressed, ctx); + } + + ctx->statusLabel = lv_label_create(parent); + lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); + lv_obj_set_style_pad_hor(ctx->statusLabel, LV_DPX(8), LV_STATE_DEFAULT); + updateStatus(ctx); + + ctx->list = lv_obj_create(parent); + lv_obj_set_width(ctx->list, LV_PCT(100)); + lv_obj_set_flex_grow(ctx->list, 1); + lv_obj_set_flex_flow(ctx->list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(ctx->list, LV_DPX(2), LV_STATE_DEFAULT); + lv_obj_set_style_border_width(ctx->list, 0, LV_STATE_DEFAULT); + // Lets the keys scroll the history + if (auto* group = lv_group_get_default()) { + lv_group_add_obj(group, ctx->list); + } + for (const auto& message : ctx->messages) { + createMessageItem(ctx, message); + } + scrollToNewest(ctx); + + auto* inputBar = lv_obj_create(parent); + lv_obj_set_size(inputBar, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(inputBar, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(inputBar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_all(inputBar, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_left(inputBar, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_pad_column(inputBar, LV_DPX(4), LV_STATE_DEFAULT); + lv_obj_set_style_border_width(inputBar, 0, LV_STATE_DEFAULT); + lv_obj_set_scrollable(inputBar, false); + + ctx->input = lv_textarea_create(inputBar); + lv_obj_set_flex_grow(ctx->input, 1); + lv_textarea_set_one_line(ctx->input, true); + lv_textarea_set_max_length(ctx->input, MESH_TEXT_MAX_LENGTH); + lv_textarea_set_placeholder_text(ctx->input, "Type a message..."); + lv_obj_add_event_cb(ctx->input, onSendPressed, LV_EVENT_READY, ctx); + + auto* sendButton = lv_button_create(inputBar); + lv_obj_add_event_cb(sendButton, onSendPressed, LV_EVENT_CLICKED, ctx); + lv_label_set_text(lv_label_create(sendButton), "Send"); +} + +void destroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + ctx->statusLabel = nullptr; + ctx->list = nullptr; + ctx->input = nullptr; +} + +/** Queries the service, so it's called without the LVGL lock */ +void loadMessages(Context* ctx) { + tt::std::vector history(MESSAGE_LIMIT); + size_t count = 0; + if (mesh_conversation_get(getPeer(ctx), ctx->channel, 0, history.data(), MESSAGE_LIMIT, &count) != ERROR_NONE) { + return; + } + // History is newest first + for (size_t i = count; i > 0; i--) { + ctx->messages.push_back(history[i - 1]); + } +} + +void onMessage(Context* ctx, uint32_t seq) { + for (const auto& known : ctx->messages) { + if (known.seq == seq) { + return; + } + } + MeshMessage message; + if (mesh_message_get(seq, &message) != ERROR_NONE || !belongsToConversation(ctx, message)) { + return; + } + ctx->messages.push_back(message); + const bool trimmed = ctx->messages.size() > MESSAGE_LIMIT; + if (trimmed) { + ctx->messages.pop_front(); + } + + lvgl_lock(); + if (ctx->list != nullptr) { + if (trimmed) { + lv_obj_delete(lv_obj_get_child(ctx->list, 0)); + } + createMessageItem(ctx, message); + scrollToNewest(ctx); + } + lvgl_unlock(); +} + +void onDelivery(Context* ctx, uint32_t seq, MeshDeliveryState delivery) { + for (auto& message : ctx->messages) { + if (message.seq != seq) { + continue; + } + message.delivery = delivery; + lvgl_lock(); + if (ctx->list != nullptr) { + const uint32_t count = lv_obj_get_child_count(ctx->list); + for (uint32_t i = 0; i < count; i++) { + auto* item = lv_obj_get_child(ctx->list, (int32_t)i); + if (static_cast(reinterpret_cast(lv_obj_get_user_data(item))) == seq) { + lv_label_set_text(lv_obj_get_child(item, 0), getHeaderText(message).c_str()); + break; + } + } + } + lvgl_unlock(); + return; + } +} + +/** Looks up the channel. @return false when it isn't known */ +bool getChannel(const Context* ctx, MeshChannel* channel) { + MeshChannel channels[8]; + size_t count = 0; + if (mesh_get_channels(channels, 8, &count) != ERROR_NONE) { + return false; + } + for (size_t i = 0; i < count; i++) { + if (channels[i].index == ctx->channel) { + *channel = channels[i]; + return true; + } + } + return false; +} + +void showChannelKey(Context* ctx) { + if (ctx->messageDialogId != 0) { + return; + } + MeshChannel channel; + if (!getChannel(ctx, &channel) || channel.key_length == 0) { + ctx->messageDialogId = alertdialog::start(ctx->appInstanceId, "Channel key", "The key isn't available."); + return; + } + MeshAddress key {}; + memcpy(key.bytes, channel.key, channel.key_length); + key.length = channel.key_length; + const ::std::string text = addressToHex(key); + ctx->messageDialogId = alertdialog::start(ctx->appInstanceId, "Channel key", text.c_str()); +} + +void startRemoveDialog(Context* ctx) { + if (ctx->removeDialogId != 0) { + return; + } + const char* title = ctx->isContact ? "Remove contact" : "Leave channel"; + const char* message = ctx->isContact ? "The messages are kept." : "The messages are kept, but new messages in this channel aren't received anymore."; + ctx->removeDialogId = alertdialog::start(ctx->appInstanceId, title, message, { "Remove", "Cancel" }); +} + +/** @return true when the conversation is gone and the app can close */ +bool onRemoveResult(Context* ctx, int32_t result) { + if (result != 0) { + return false; + } + const error_t error = ctx->isContact ? mesh_remove_contact(&ctx->peer) : mesh_remove_channel(ctx->channel); + if (error != ERROR_NONE && error != ERROR_NOT_FOUND) { + LOG_E(TAG, "Failed to remove: %s", error_to_string(error)); + ctx->messageDialogId = alertdialog::start(ctx->appInstanceId, "Couldn't remove", error_to_string(error)); + return false; + } + return true; +} + +int32_t appMain(int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = app_scheduler_current_app_id(); + if (argc < 3) { + return 1; + } + ctx.isContact = strcmp(argv[0], KIND_CONTACT) == 0; + if (ctx.isContact) { + if (!hexToAddress(argv[1], &ctx.peer)) { + return 1; + } + } else { + ctx.channel = (uint8_t)atoi(argv[1]); + MeshChannel channel; + ctx.isPrivateChannel = ctx.channel != 0 && getChannel(&ctx, &channel) && channel.name[0] != '#'; + } + ctx.title = argv[2]; + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + ctx.eventGroup = &eventGroup; + check(task_event_group_claim_bit(&eventGroup, &ctx.removeBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.infoBit) == ERROR_NONE); + + AppEventSubscription appEvents {}; + check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); + MeshEventSubscription meshEvents {}; + check(mesh_event_subscribe(&meshEvents, &eventGroup) == ERROR_NONE); + + // The window shows up first, and the history follows + WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); + loadMessages(&ctx); + lvgl_lock(); + if (ctx.list != nullptr) { + // The window may have been rebuilt from the messages meanwhile + lv_obj_clean(ctx.list); + for (const auto& message : ctx.messages) { + createMessageItem(&ctx, message); + } + scrollToNewest(&ctx); + } + lvgl_unlock(); + + bool shouldClose = false; + while (!shouldClose) { + uint32_t flags = 0; + task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY); + + if (flags & ctx.removeBit) { + startRemoveDialog(&ctx); + } + if (flags & ctx.infoBit) { + showChannelKey(&ctx); + } + + MeshEvent meshEvent {}; + while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { + switch (meshEvent.type) { + case MESH_EVENT_MESSAGE: + onMessage(&ctx, meshEvent.seq); + break; + case MESH_EVENT_DELIVERY: + onDelivery(&ctx, meshEvent.seq, meshEvent.delivery); + break; + case MESH_EVENT_STATE: + lvgl_lock(); + updateStatus(&ctx); + lvgl_unlock(); + break; + default: + break; + } + } + + AppEvent event {}; + while (app_event_poll(&appEvents, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + if (event.type == APP_EVENT_RESULT) { + if (event.result.launch_id == ctx.removeDialogId) { + ctx.removeDialogId = 0; + shouldClose |= onRemoveResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.messageDialogId) { + ctx.messageDialogId = 0; + } + app_manager_stop(event.result.launch_id); + } + } + } + + window_manager_remove(window); + check(mesh_event_unsubscribe(&meshEvents) == ERROR_NONE); + check(app_event_unsubscribe(&appEvents) == ERROR_NONE); + task_event_group_destruct(&eventGroup); + return 0; +} + +} // namespace + +extern const ::AppManifest conversationManifest = { + .id = CONVERSATION_APP_ID, + .name = "Mesh Conversation", + .category = APP_CATEGORY_USER, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, + .stack = { .depth = 5120, .desired_memory_capability = 0 }, +}; + +} // namespace tt::app::meshchat + diff --git a/Tactility/Source/app/meshinspect/MeshInspect.cpp b/Tactility/Source/app/meshinspect/MeshInspect.cpp deleted file mode 100644 index bf0a91250..000000000 --- a/Tactility/Source/app/meshinspect/MeshInspect.cpp +++ /dev/null @@ -1,534 +0,0 @@ -#if TT_FEATURE_MESH - -#include -#include -#include - -#include - -#include -#include -#include -#include - -#include - -#include -#include -#include - -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -namespace tt::app::meshinspect { - -extern const ::AppManifest manifest; - -namespace { - -constexpr size_t ENTRY_LIMIT = 50; -constexpr size_t LOAD_THREAD_STACK_SIZE = 4096; - -/** A stored message, or a frame that was only reported live (seq 0) */ -struct Entry { - MeshMessage message; - bool isFrame; - MeshFrameKind frameKind; - uint16_t frameLength; -}; - -struct Context { - uint32_t appInstanceId; - /** Oldest first */ - tt::std::deque entries; - /** Channel names by channel index */ - tt::std::map channelNames; - /** Set until the loaded data is shown. Written by the app task under the LVGL lock */ - bool loading = true; - /** Delivery updates for messages that may be part of the history that's still loading */ - tt::std::map pendingDeliveries; - TaskEventGroup* eventGroup = nullptr; - uint32_t loadedBit = 0; - Thread* loadThread = nullptr; - ::std::atomic loadCancelled = false; - // Written by the load thread until it signals loadedBit - tt::std::deque loadedEntries; - tt::std::map loadedChannelNames; - // Valid while the window is shown - lv_obj_t* statusLabel = nullptr; - lv_obj_t* list = nullptr; - lv_obj_t* emptyLabel = nullptr; -}; - -const char* getDeliveryText(MeshDeliveryState delivery) { - switch (delivery) { - case MESH_DELIVERY_QUEUED: - return "queued"; - case MESH_DELIVERY_BLOCKED: - return "blocked"; - case MESH_DELIVERY_SENT: - return "sent"; - case MESH_DELIVERY_DELIVERED: - return "delivered"; - case MESH_DELIVERY_FAILED: - return "failed"; - case MESH_DELIVERY_CANCELLED: - return "cancelled"; - default: - return ""; - } -} - -::std::string getAddressText(const MeshAddress& address) { - // The first bytes identify the node well enough to tell nodes apart - ::std::string text; - char hex[3]; - for (size_t i = 0; i < address.length && i < 4; i++) { - snprintf(hex, sizeof(hex), "%02x", address.bytes[i]); - text += hex; - } - return text; -} - -::std::string getChannelText(const Context* ctx, uint8_t channel) { - const auto iterator = ctx->channelNames.find(channel); - return (iterator != ctx->channelNames.end()) ? ::std::string(iterator->second.c_str()) : ("channel " + ::std::to_string(channel)); -} - -::std::string getSignalText(float rssi, float snr) { - char text[40]; - if (::std::isnan(snr)) { - snprintf(text, sizeof(text), "%.0f dBm", rssi); - } else { - snprintf(text, sizeof(text), "%.0f dBm, %.1f dB", rssi, snr); - } - return text; -} - -::std::string getFrameHeaderText(const Entry& entry) { - const auto& message = entry.message; - char header[160]; - if (entry.frameKind == MESH_FRAME_ADVERT) { - snprintf(header, sizeof(header), "%s < advert %s %s", message.backend, getAddressText(message.peer).c_str(), getSignalText(message.rssi, message.snr).c_str()); - } else { - snprintf(header, sizeof(header), "%s < %u bytes %s", message.backend, (unsigned)entry.frameLength, getSignalText(message.rssi, message.snr).c_str()); - } - return header; -} - -::std::string getHeaderText(const Context* ctx, const Entry& entry) { - if (entry.isFrame) { - return getFrameHeaderText(entry); - } - const auto& message = entry.message; - ::std::string target = (message.peer.length == 0) ? getChannelText(ctx, message.channel) : getAddressText(message.peer); - char header[160]; - if (message.outgoing) { - snprintf(header, sizeof(header), "%s > %s [%s]", message.backend, target.c_str(), getDeliveryText(message.delivery)); - } else { - const char* sender = (message.sender_name[0] != '\0') ? message.sender_name : "?"; - snprintf(header, sizeof(header), "%s < %s %s %s", message.backend, sender, target.c_str(), getSignalText(message.rssi, message.snr).c_str()); - } - return header; -} - -::std::string getStatusText() { - char backend[MESH_BACKEND_ID_MAX_LENGTH + 1] = "-"; - mesh_get_backend(backend, sizeof(backend)); - return ::std::string(backend) + ": " + mesh_state_to_string(mesh_get_state()); -} - -const char* getBodyText(const Entry& entry) { - if (!entry.isFrame) { - return entry.message.text; - } - if (entry.frameKind == MESH_FRAME_ADVERT) { - return (entry.message.sender_name[0] != '\0') ? entry.message.sender_name : "[no name]"; - } - return "[could not read]"; -} - -/** Requires the LVGL lock */ -lv_obj_t* createEntryWidget(Context* ctx, const Entry& entry) { - const auto& message = entry.message; - // Shows a focus ring when the keys select it - auto* item = lvgl_focus_container_create(ctx->list); - lv_obj_set_size(item, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(item, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(item, 4, LV_STATE_DEFAULT); - lv_obj_set_style_pad_row(item, 0, LV_STATE_DEFAULT); - lv_obj_set_scrollable(item, false); - lv_obj_set_user_data(item, reinterpret_cast(static_cast(message.seq))); - - auto* header = lv_label_create(item); - lv_obj_set_width(header, LV_PCT(100)); - lv_obj_set_style_text_font(header, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT); - lv_label_set_text(header, getHeaderText(ctx, entry).c_str()); - - auto* text = lv_label_create(item); - lv_obj_set_width(text, LV_PCT(100)); - lv_label_set_long_mode(text, LV_LABEL_LONG_MODE_WRAP); - lv_label_set_text(text, getBodyText(entry)); - return item; -} - -/** Requires the LVGL lock */ -void selectEntryWidget(Context* ctx, lv_obj_t* item, lv_obj_t* previous) { - // The new item has no position until the layout is updated, and removed items leave the scroll position past the end - lv_obj_update_layout(ctx->list); - lv_obj_readjust_scroll(ctx->list, LV_ANIM_OFF); - // Grid navigation selects the first entry by default, which would make the next entries count as browsing - const bool showRing = previous != nullptr && lv_obj_has_state(previous, LV_STATE_FOCUS_KEY); - lv_gridnav_set_focused(ctx->list, item, LV_ANIM_OFF); - if (!showRing) { - lv_obj_remove_state(item, LV_STATE_FOCUS_KEY); - } - if (!lv_obj_has_state(ctx->list, LV_STATE_FOCUSED)) { - lv_obj_remove_state(item, LV_STATE_FOCUSED); - } -} - -/** Requires the LVGL lock */ -void addEntryWidget(Context* ctx, const Entry& entry) { - // Keep the view on the newest entry, unless the keys selected an older one - lv_group_t* group = lv_group_get_default(); - lv_obj_t* focused = lvgl_grid_navigation_get_focused(ctx->list); - const uint32_t childCount = lv_obj_get_child_count(ctx->list); - const bool browsing = group != nullptr && lv_group_get_focused(group) == ctx->list && focused != nullptr && - focused != lv_obj_get_child(ctx->list, (int32_t)childCount - 1); - - auto* item = createEntryWidget(ctx, entry); - lv_obj_set_hidden(ctx->emptyLabel, true); - if (browsing) { - lv_obj_update_layout(ctx->list); - lv_obj_readjust_scroll(ctx->list, LV_ANIM_OFF); - } else { - selectEntryWidget(ctx, item, focused); - } -} - -/** Requires the LVGL lock */ -void addAllEntryWidgets(Context* ctx) { - if (ctx->entries.empty()) { - return; - } - lv_obj_t* focused = lvgl_grid_navigation_get_focused(ctx->list); - lv_obj_t* item = nullptr; - for (const auto& entry : ctx->entries) { - item = createEntryWidget(ctx, entry); - } - lv_obj_set_hidden(ctx->emptyLabel, true); - selectEntryWidget(ctx, item, focused); -} - -/** Requires the LVGL lock */ -void updateEntryWidget(Context* ctx, const Entry& entry) { - const auto& message = entry.message; - const uint32_t count = lv_obj_get_child_count(ctx->list); - for (uint32_t i = 0; i < count; i++) { - auto* item = lv_obj_get_child(ctx->list, (int32_t)i); - if (static_cast(reinterpret_cast(lv_obj_get_user_data(item))) == message.seq) { - lv_label_set_text(lv_obj_get_child(item, 0), getHeaderText(ctx, entry).c_str()); - return; - } - } -} - -void onBackPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - app_event_emit_close(ctx->appInstanceId); -} - -void createWidgets(lv_obj_t* parent, void* userData) { - auto* ctx = static_cast(userData); - - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - auto* toolbar = lvgl_toolbar_create(parent, "Mesh Inspect"); - lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - - ctx->statusLabel = lv_label_create(parent); - lv_obj_set_width(ctx->statusLabel, LV_PCT(100)); - lv_obj_set_style_pad_hor(ctx->statusLabel, 8, LV_STATE_DEFAULT); - lv_obj_set_style_text_font(ctx->statusLabel, lvgl_get_text_font(FONT_SIZE_SMALL), LV_STATE_DEFAULT); - lv_label_set_text(ctx->statusLabel, ctx->loading ? "Loading..." : getStatusText().c_str()); - - ctx->list = lv_obj_create(parent); - lv_obj_set_width(ctx->list, LV_PCT(100)); - lv_obj_set_flex_grow(ctx->list, 1); - lv_obj_set_flex_flow(ctx->list, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(ctx->list, 4, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ctx->list, 0, LV_STATE_DEFAULT); - - ctx->emptyLabel = lv_label_create(ctx->list); - lv_label_set_text(ctx->emptyLabel, "No messages yet"); - // The arrow keys move between the entries - lvgl_grid_navigation_add(ctx->list); - - if (!ctx->loading) { - addAllEntryWidgets(ctx); - } -} - -void destroyWidgets(void* userData) { - auto* ctx = static_cast(userData); - ctx->statusLabel = nullptr; - ctx->list = nullptr; - ctx->emptyLabel = nullptr; -} - -/** Queries the service thread, so it's called without the LVGL lock */ -void loadChannelNames(tt::std::map& channelNames) { - MeshChannel channels[8]; - size_t count = 0; - if (mesh_get_channels(channels, 8, &count) != ERROR_NONE) { - return; - } - channelNames.clear(); - for (size_t i = 0; i < count; i++) { - channelNames[channels[i].index] = channels[i].name; - } -} - -void loadHistory(tt::std::deque& entries) { - tt::std::vector history(ENTRY_LIMIT); - size_t count = 0; - if (mesh_history_get(0, history.data(), ENTRY_LIMIT, &count) != ERROR_NONE) { - return; - } - // History is newest first - for (size_t i = count; i > 0; i--) { - entries.push_back({ .message = history[i - 1], .isFrame = false, .frameKind = MESH_FRAME_UNREADABLE, .frameLength = 0 }); - } -} - -void addEntry(Context* ctx, const Entry& entry) { - ctx->entries.push_back(entry); - const bool trimmed = ctx->entries.size() > ENTRY_LIMIT; - if (trimmed) { - ctx->entries.pop_front(); - } - - if (ctx->loading) { - return; - } - lvgl_lock(); - if (ctx->list != nullptr) { - if (trimmed) { - // Index 0 is the "no messages" label - lv_obj_delete(lv_obj_get_child(ctx->list, 1)); - } - addEntryWidget(ctx, entry); - } - lvgl_unlock(); -} - -void onMessage(Context* ctx, uint32_t seq) { - MeshMessage message; - if (mesh_message_get(seq, &message) == ERROR_NONE) { - addEntry(ctx, { .message = message, .isFrame = false, .frameKind = MESH_FRAME_UNREADABLE, .frameLength = 0 }); - } -} - -void onFrame(Context* ctx, const MeshEvent& event) { - Entry entry {}; - entry.isFrame = true; - entry.frameKind = event.frame.kind; - entry.frameLength = event.frame.length; - mesh_get_backend(entry.message.backend, sizeof(entry.message.backend)); - entry.message.peer = event.frame.from; - strncpy(entry.message.sender_name, event.frame.name, MESH_NAME_MAX_LENGTH); - entry.message.rssi = event.frame.rssi; - entry.message.snr = event.frame.snr; - addEntry(ctx, entry); -} - -void onDelivery(Context* ctx, uint32_t seq, MeshDeliveryState delivery) { - for (auto& entry : ctx->entries) { - if (!entry.isFrame && entry.message.seq == seq) { - entry.message.delivery = delivery; - lvgl_lock(); - if (ctx->list != nullptr) { - updateEntryWidget(ctx, entry); - } - lvgl_unlock(); - return; - } - } - if (ctx->loading) { - ctx->pendingDeliveries[seq] = delivery; - } -} - -/** Queries the service thread, so the window can show while it waits */ -int32_t loadThreadMain(void* context) { - auto* ctx = static_cast(context); - if (!ctx->loadCancelled) { - loadChannelNames(ctx->loadedChannelNames); - } - if (!ctx->loadCancelled) { - loadHistory(ctx->loadedEntries); - } - task_event_group_signal(ctx->eventGroup, ctx->loadedBit); - return 0; -} - -bool startLoading(Context* ctx) { - ctx->loadThread = thread_alloc(); - if (ctx->loadThread == nullptr) { - return false; - } - thread_set_name(ctx->loadThread, "mesh_inspect_load"); - thread_set_stack_size(ctx->loadThread, LOAD_THREAD_STACK_SIZE); - thread_set_main_function(ctx->loadThread, loadThreadMain, ctx); - if (thread_start(ctx->loadThread) != ERROR_NONE) { - thread_free(ctx->loadThread); - ctx->loadThread = nullptr; - return false; - } - return true; -} - -void joinLoadThread(Context* ctx) { - if (ctx->loadThread != nullptr) { - thread_join(ctx->loadThread, portMAX_DELAY, pdMS_TO_TICKS(10)); - thread_free(ctx->loadThread); - ctx->loadThread = nullptr; - } -} - -/** Puts the history before the entries that arrived while it loaded, then shows them all at once */ -void onLoaded(Context* ctx) { - joinLoadThread(ctx); - ctx->channelNames = ::std::move(ctx->loadedChannelNames); - for (auto iterator = ctx->loadedEntries.rbegin(); iterator != ctx->loadedEntries.rend(); ++iterator) { - bool known = false; - for (const auto& entry : ctx->entries) { - if (!entry.isFrame && entry.message.seq == iterator->message.seq) { - known = true; - break; - } - } - if (!known) { - ctx->entries.push_front(*iterator); - } - } - ctx->loadedEntries.clear(); - while (ctx->entries.size() > ENTRY_LIMIT) { - ctx->entries.pop_front(); - } - for (auto& entry : ctx->entries) { - const auto pending = ctx->pendingDeliveries.find(entry.message.seq); - if (!entry.isFrame && pending != ctx->pendingDeliveries.end()) { - entry.message.delivery = pending->second; - } - } - ctx->pendingDeliveries.clear(); - - lvgl_lock(); - ctx->loading = false; - if (ctx->list != nullptr) { - lv_label_set_text(ctx->statusLabel, getStatusText().c_str()); - addAllEntryWidgets(ctx); - } - lvgl_unlock(); -} - -int32_t appMain(int argc, char* argv[]) { - Context ctx {}; - ctx.appInstanceId = app_scheduler_current_app_id(); - - TaskEventGroup eventGroup {}; - task_event_group_construct(&eventGroup); - ctx.eventGroup = &eventGroup; - check(task_event_group_claim_bit(&eventGroup, &ctx.loadedBit) == ERROR_NONE); - AppEventSubscription appEvents {}; - check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); - MeshEventSubscription meshEvents {}; - check(mesh_event_subscribe(&meshEvents, &eventGroup) == ERROR_NONE); - - WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); - // Shows "Loading..." before the load thread competes for the CPU - lvgl_lock(); - lv_refr_now(nullptr); - lvgl_unlock(); - if (!startLoading(&ctx)) { - task_event_group_signal(&eventGroup, ctx.loadedBit); - } - - bool shouldClose = false; - while (!shouldClose) { - uint32_t flags = 0; - task_event_group_wait_any(&eventGroup, &flags, portMAX_DELAY); - - if (flags & ctx.loadedBit) { - onLoaded(&ctx); - } - - MeshEvent meshEvent {}; - while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { - switch (meshEvent.type) { - case MESH_EVENT_MESSAGE: - onMessage(&ctx, meshEvent.seq); - break; - case MESH_EVENT_FRAME: - onFrame(&ctx, meshEvent); - break; - case MESH_EVENT_DELIVERY: - onDelivery(&ctx, meshEvent.seq, meshEvent.delivery); - break; - case MESH_EVENT_STATE: - loadChannelNames(ctx.channelNames); - lvgl_lock(); - if (ctx.statusLabel != nullptr && !ctx.loading) { - lv_label_set_text(ctx.statusLabel, getStatusText().c_str()); - } - lvgl_unlock(); - break; - default: - break; - } - } - - AppEvent event {}; - while (app_event_poll(&appEvents, &event) == ERROR_NONE) { - if (event.type == APP_EVENT_CLOSE) { - shouldClose = true; - break; - } - } - } - - ctx.loadCancelled = true; - joinLoadThread(&ctx); - window_manager_remove(window); - check(mesh_event_unsubscribe(&meshEvents) == ERROR_NONE); - check(app_event_unsubscribe(&appEvents) == ERROR_NONE); - task_event_group_destruct(&eventGroup); - return 0; -} - -} // namespace - -extern const ::AppManifest manifest = { - .id = "tactility.meshinspect", - .name = "Mesh Inspect", - .category = APP_CATEGORY_USER, - .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, - .flags = 0, - .stack = {} -}; - -} // namespace tt::app::meshinspect - -#endif diff --git a/Tactility/Source/app/meshsettings/MeshSettings.cpp b/Tactility/Source/app/meshsettings/MeshSettings.cpp index b223db06a..575c6945b 100644 --- a/Tactility/Source/app/meshsettings/MeshSettings.cpp +++ b/Tactility/Source/app/meshsettings/MeshSettings.cpp @@ -1,6 +1,12 @@ -#if TT_FEATURE_MESH +#include "app/location.h" +#ifdef ESP_PLATFORM +#include +#else +#include +#endif #include +#include #include #include @@ -44,19 +50,40 @@ struct Context { uint32_t selectRegionBit = 0; uint32_t selectPresetBit = 0; uint32_t addChannelBit = 0; + uint32_t changeNameBit = 0; + uint32_t selectRadioBit = 0; uint32_t serviceDialogId = 0; uint32_t regionDialogId = 0; uint32_t presetDialogId = 0; uint32_t channelDialogId = 0; + uint32_t nameDialogId = 0; + uint32_t radioDialogId = 0; AppStream channelStream {}; char channelBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + AppStream nameStream {}; + char nameBuffer[MESH_NAME_MAX_LENGTH + 1] = {}; + // Shown by updateLabels(), which can run on another app's thread and so must not query the services. + // The app's own thread fills these with loadState(). + std::string serviceText; + std::string nameText; + std::string radioText = "-"; + bool radioChoice = false; + std::string regionText; + std::string presetText; + bool hasPresets = false; + std::string statusText; /** Channel names of the active backend, empty when it has no channels */ std::string channelsText; /** Index 0 is "Disabled", the rest are backend ids */ std::vector serviceIds; std::vector presetNames; + /** Device names of the radios, in the order of the selection dialog */ + std::vector radioNames; // Valid while the window is shown lv_obj_t* serviceLabel = nullptr; + lv_obj_t* nameLabel = nullptr; + lv_obj_t* radioRow = nullptr; + lv_obj_t* radioLabel = nullptr; lv_obj_t* regionLabel = nullptr; lv_obj_t* presetRow = nullptr; lv_obj_t* presetLabel = nullptr; @@ -92,17 +119,16 @@ void updateLabels(Context* ctx) { if (ctx->serviceLabel == nullptr) { return; } - lv_label_set_text(ctx->serviceLabel, getCurrentService().c_str()); - lv_label_set_text(ctx->regionLabel, settings::radio::getRegionLabel(settings::radio::loadRegion())); - char preset[MESH_NAME_MAX_LENGTH + 1]; - const bool hasPresets = mesh_is_enabled() && mesh_get_preset(preset, sizeof(preset)) == ERROR_NONE; - lv_obj_set_hidden(ctx->presetRow, !hasPresets); - if (hasPresets) { - lv_label_set_text(ctx->presetLabel, preset); - } + lv_label_set_text(ctx->serviceLabel, ctx->serviceText.c_str()); + lv_label_set_text(ctx->nameLabel, ctx->nameText.c_str()); + lv_obj_set_hidden(ctx->radioRow, !ctx->radioChoice); + lv_label_set_text(ctx->radioLabel, ctx->radioText.c_str()); + lv_label_set_text(ctx->regionLabel, ctx->regionText.c_str()); + lv_obj_set_hidden(ctx->presetRow, !ctx->hasPresets); + lv_label_set_text(ctx->presetLabel, ctx->presetText.c_str()); lv_obj_set_hidden(ctx->channelsRow, ctx->channelsText.empty()); lv_label_set_text(ctx->channelsLabel, ctx->channelsText.c_str()); - lv_label_set_text(ctx->statusLabel, getStatus().c_str()); + lv_label_set_text(ctx->statusLabel, ctx->statusText.c_str()); } /** Queries the service thread, so it's called without the LVGL lock */ @@ -121,11 +147,100 @@ void loadChannels(Context* ctx) { } } +/** The device names of the radios, and the names to show for them: the kind of radio, plus the device name when there are several of a kind */ +void loadRadios(std::vector& names, std::vector& labels) { + names.clear(); + labels.clear(); + std::vector kinds; + const size_t count = mesh_get_radio_count(); + for (size_t i = 0; i < count; i++) { + char name[MESH_NAME_MAX_LENGTH + 1]; + MeshRadioKind kind; + if (mesh_get_radio_name(i, name, sizeof(name)) == ERROR_NONE && mesh_get_radio_kind(i, &kind) == ERROR_NONE) { + names.emplace_back(name); + kinds.push_back(kind); + } + } + for (size_t i = 0; i < names.size(); i++) { + const char* kindText = (kinds[i] == MESH_RADIO_LORA) ? "LoRa" : "ESP-NOW"; + const auto sameKind = std::count(kinds.begin(), kinds.end(), kinds[i]); + labels.push_back(sameKind > 1 ? std::string(kindText) + " " + names[i] : std::string(kindText)); + } +} + +/** Reads everything that updateLabels() shows. Queries the service thread, so it's called without the LVGL lock. */ +void loadState(Context* ctx) { + loadChannels(ctx); + ctx->serviceText = getCurrentService(); + char name[MESH_NAME_MAX_LENGTH + 1]; + mesh_get_node_name(name, sizeof(name)); + ctx->nameText = name; + // A choice only matters when there is more than one radio + char radio[MESH_NAME_MAX_LENGTH + 1] = "-"; + mesh_get_radio(radio, sizeof(radio)); + std::vector radioNames; + std::vector radioLabels; + loadRadios(radioNames, radioLabels); + ctx->radioText = radio; + for (size_t i = 0; i < radioNames.size(); i++) { + if (radioNames[i] == radio) { + ctx->radioText = radioLabels[i]; + } + } + ctx->radioChoice = radioNames.size() >= 2; + ctx->regionText = settings::radio::getRegionLabel(settings::radio::loadRegion()); + char preset[MESH_NAME_MAX_LENGTH + 1]; + ctx->hasPresets = mesh_is_enabled() && mesh_get_preset(preset, sizeof(preset)) == ERROR_NONE; + ctx->presetText = ctx->hasPresets ? preset : ""; + ctx->statusText = getStatus(); +} + void onAddChannelPressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); task_event_group_signal(ctx->eventGroup, ctx->addChannelBit); } +void onSelectRadioPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->selectRadioBit); +} + +void onChangeNamePressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + task_event_group_signal(ctx->eventGroup, ctx->changeNameBit); +} + +void startNameDialog(Context* ctx) { + if (ctx->nameDialogId != 0) { + return; + } + char name[MESH_NAME_MAX_LENGTH + 1]; + mesh_get_node_name(name, sizeof(name)); + ctx->nameDialogId = inputdialog::start( + ctx->appInstanceId, + "Name", + "The name that other nodes see", + name, + ctx->nameStream, + ctx->nameBuffer, + sizeof(ctx->nameBuffer), + ctx->eventGroup + ); +} + +void onNameDialogResult(Context* ctx, int32_t result) { + if (result == 0) { + char name[sizeof(Context::nameBuffer)] = {}; + const size_t length = app_stream_read(&ctx->nameStream, name, sizeof(name) - 1); + name[length] = '\0'; + const error_t error = mesh_set_node_name(name); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to set the name: %s", error_to_string(error)); + } + } + app_stream_unsubscribe(&ctx->nameStream); +} + void startChannelDialog(Context* ctx) { if (ctx->channelDialogId != 0) { return; @@ -142,41 +257,15 @@ void startChannelDialog(Context* ctx) { ); } -void joinChannel(const char* name) { - if (name[name[0] == '#' ? 1 : 0] == '\0') { - return; - } - - // A new channel gets the next free index - MeshChannel channels[8]; - size_t count = 0; - uint8_t index = 0; - if (mesh_get_channels(channels, 8, &count) == ERROR_NONE) { - for (size_t i = 0; i < count; i++) { - if (strcmp(channels[i].name, name) == 0) { - index = channels[i].index; - break; - } - index = std::max(index, channels[i].index + 1); - } - } - - MeshChannel channel {}; - channel.index = index; - strncpy(channel.name, name, MESH_NAME_MAX_LENGTH); - channel.key_length = 0; - const error_t error = mesh_set_channel(&channel); - if (error != ERROR_NONE) { - LOG_E(TAG, "Failed to join channel %s: %s", name, error_to_string(error)); - } -} - void onChannelDialogResult(Context* ctx, int32_t result) { if (result == 0) { char name[sizeof(Context::channelBuffer)] = {}; const size_t length = app_stream_read(&ctx->channelStream, name, sizeof(name) - 1); name[length] = '\0'; - joinChannel(name); + const error_t error = joinHashtagChannel(name); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to join channel %s: %s", name, error_to_string(error)); + } } app_stream_unsubscribe(&ctx->channelStream); } @@ -230,6 +319,26 @@ void onServiceSelected(Context* ctx, int32_t selected) { } } +void startRadioSelection(Context* ctx) { + if (ctx->radioDialogId != 0) { + return; + } + std::vector labels; + loadRadios(ctx->radioNames, labels); + if (ctx->radioNames.size() > 1) { + ctx->radioDialogId = selectiondialog::start(ctx->appInstanceId, "Radio", labels); + } +} + +void onRadioSelected(Context* ctx, int32_t selected) { + if (selected >= 0 && selected < static_cast(ctx->radioNames.size())) { + const error_t error = mesh_set_radio(ctx->radioNames[selected].c_str()); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to select the radio: %s", error_to_string(error)); + } + } +} + void startRegionSelection(Context* ctx) { if (ctx->regionDialogId != 0) { return; @@ -337,6 +446,14 @@ void createWidgets(lv_obj_t* parent, void* userData) { ctx->serviceLabel = createTitleAndValue(serviceRow, "Mesh network"); createChangeButton(serviceRow, onSelectServicePressed, ctx); + auto* nameRow = createRow(card); + ctx->nameLabel = createTitleAndValue(nameRow, "Name"); + createChangeButton(nameRow, onChangeNamePressed, ctx); + + ctx->radioRow = createRow(card); + ctx->radioLabel = createTitleAndValue(ctx->radioRow, "Radio"); + createChangeButton(ctx->radioRow, onSelectRadioPressed, ctx); + auto* regionRow = createRow(card); ctx->regionLabel = createTitleAndValue(regionRow, "Region"); createChangeButton(regionRow, onSelectRegionPressed, ctx); @@ -360,6 +477,9 @@ void createWidgets(lv_obj_t* parent, void* userData) { void destroyWidgets(void* userData) { auto* ctx = static_cast(userData); ctx->serviceLabel = nullptr; + ctx->nameLabel = nullptr; + ctx->radioRow = nullptr; + ctx->radioLabel = nullptr; ctx->regionLabel = nullptr; ctx->presetRow = nullptr; ctx->presetLabel = nullptr; @@ -379,7 +499,9 @@ int32_t appMain(int argc, char* argv[]) { check(task_event_group_claim_bit(&eventGroup, &ctx.selectRegionBit) == ERROR_NONE); check(task_event_group_claim_bit(&eventGroup, &ctx.selectPresetBit) == ERROR_NONE); check(task_event_group_claim_bit(&eventGroup, &ctx.addChannelBit) == ERROR_NONE); - loadChannels(&ctx); + check(task_event_group_claim_bit(&eventGroup, &ctx.changeNameBit) == ERROR_NONE); + check(task_event_group_claim_bit(&eventGroup, &ctx.selectRadioBit) == ERROR_NONE); + loadState(&ctx); AppEventSubscription appEvents {}; check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); @@ -405,11 +527,17 @@ int32_t appMain(int argc, char* argv[]) { if (flags & ctx.addChannelBit) { startChannelDialog(&ctx); } + if (flags & ctx.changeNameBit) { + startNameDialog(&ctx); + } + if (flags & ctx.selectRadioBit) { + startRadioSelection(&ctx); + } bool changed = false; MeshEvent meshEvent {}; while (mesh_event_poll(&meshEvents, &meshEvent) == ERROR_NONE) { - changed |= meshEvent.type == MESH_EVENT_STATE; + changed |= meshEvent.type == MESH_EVENT_STATE || meshEvent.type == MESH_EVENT_CHANNEL; } AppEvent event {}; @@ -431,6 +559,12 @@ int32_t appMain(int argc, char* argv[]) { } else if (event.result.launch_id == ctx.channelDialogId) { ctx.channelDialogId = 0; onChannelDialogResult(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.radioDialogId) { + ctx.radioDialogId = 0; + onRadioSelected(&ctx, event.result.result); + } else if (event.result.launch_id == ctx.nameDialogId) { + ctx.nameDialogId = 0; + onNameDialogResult(&ctx, event.result.result); } app_manager_stop(event.result.launch_id); changed = true; @@ -438,7 +572,7 @@ int32_t appMain(int argc, char* argv[]) { } if (changed) { - loadChannels(&ctx); + loadState(&ctx); lvgl_lock(); updateLabels(&ctx); lvgl_unlock(); @@ -454,15 +588,82 @@ int32_t appMain(int argc, char* argv[]) { } // namespace +/** A new channel gets the next free index, and a channel with the same name keeps its index */ +static error_t findChannelIndex(const char* name, uint8_t* index) { + MeshChannel channels[8]; + size_t count = 0; + const error_t error = mesh_get_channels(channels, 8, &count); + if (error != ERROR_NONE) { + return error; + } + *index = 0; + for (size_t i = 0; i < count; i++) { + if (strcmp(channels[i].name, name) == 0) { + *index = channels[i].index; + return ERROR_NONE; + } + *index = std::max(*index, channels[i].index + 1); + } + return ERROR_NONE; +} + +error_t joinHashtagChannel(const char* name) { + if (name[name[0] == '#' ? 1 : 0] == '\0') { + return ERROR_INVALID_ARGUMENT; + } + + MeshChannel channel {}; + const error_t error = findChannelIndex(name, &channel.index); + if (error != ERROR_NONE) { + return error; + } + strncpy(channel.name, name, MESH_NAME_MAX_LENGTH); + channel.key_length = 0; + return mesh_set_channel(&channel); +} + +/** Sets a channel with a key on the next free index */ +static error_t setKeyedChannel(const char* name, const uint8_t* key, size_t keyLength) { + if (name[0] == '\0' || keyLength == 0 || keyLength > MESH_CHANNEL_KEY_MAX_LENGTH) { + return ERROR_INVALID_ARGUMENT; + } + + MeshChannel channel {}; + const error_t error = findChannelIndex(name, &channel.index); + if (error != ERROR_NONE) { + return error; + } + strncpy(channel.name, name, MESH_NAME_MAX_LENGTH); + channel.key_length = static_cast(keyLength); + memcpy(channel.key, key, keyLength); + return mesh_set_channel(&channel); +} + +error_t createPrivateChannel(const char* name) { + uint8_t key[16]; +#ifdef ESP_PLATFORM + esp_fill_random(key, sizeof(key)); +#else + std::random_device device; + for (auto& byte : key) { + byte = static_cast(device()); + } +#endif + return setKeyedChannel(name, key, sizeof(key)); +} + +error_t joinPrivateChannel(const char* name, const uint8_t* key, size_t keyLength) { + return setKeyedChannel(name, key, keyLength); +} + extern const ::AppManifest manifest = { .id = "tactility.meshsettings", .name = "Mesh", .category = APP_CATEGORY_SETTINGS, - .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast(appMain) }, .flags = 0, - .stack = {} + .stack = { .depth = 6144, .desired_memory_capability = 0 }, }; } // namespace tt::app::meshsettings -#endif diff --git a/Tactility/Source/app/qrcode/QrCode.cpp b/Tactility/Source/app/qrcode/QrCode.cpp new file mode 100644 index 000000000..b63478939 --- /dev/null +++ b/Tactility/Source/app/qrcode/QrCode.cpp @@ -0,0 +1,129 @@ +#include + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include +#include + +namespace tt::app::qrcode { + +constexpr auto* TAG = "QrCode"; + +extern const ::AppManifest manifest; + +namespace { + +struct Context { + uint32_t appInstanceId; + // Set once in appMain() from its own argc/argv parameters, read by createWidgets() + int argc = 0; + char** argv = nullptr; +}; + +// A touch, a key press or a step of an encoder that is handled as keys +void onInput(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + app_event_emit_close(ctx->appInstanceId); +} + +void forEachInputDevice(void (*action)(lv_indev_t* indev, Context* ctx), Context* ctx) { + for (lv_indev_t* indev = lv_indev_get_next(nullptr); indev != nullptr; indev = lv_indev_get_next(indev)) { + action(indev, ctx); + } +} + +void createWidgets(lv_obj_t* parent, void* userData) { + auto* ctx = static_cast(userData); + + lv_obj_set_scrollable(parent, false); + lv_obj_set_style_pad_all(parent, 0, LV_STATE_DEFAULT); + lv_obj_set_style_border_width(parent, 0, LV_STATE_DEFAULT); + + auto* qr = lvgl_qr_create(parent); + lv_obj_set_size(qr, LV_PCT(100), LV_PCT(100)); + const char* text = (ctx->argc > 0) ? ctx->argv[0] : ""; + const error_t error = lvgl_qr_set_data(qr, text); + if (error != ERROR_NONE) { + LOG_E(TAG, "Failed to create the QR code: %s", error_to_string(error)); + auto* label = lv_label_create(parent); + lv_label_set_text(label, "The data doesn't fit in a QR code"); + lv_obj_center(label); + } + + // Any input device can close it, whichever widget has the focus + forEachInputDevice([](lv_indev_t* indev, Context* ctx) { + lv_indev_add_event_cb(indev, onInput, LV_EVENT_PRESSED, ctx); + }, ctx); +} + +void destroyWidgets(void* userData) { + forEachInputDevice([](lv_indev_t* indev, Context* ctx) { + lv_indev_remove_event_cb_with_user_data(indev, onInput, ctx); + }, static_cast(userData)); +} + +int32_t appMain(int argc, char* argv[]) { + Context ctx {}; + ctx.appInstanceId = app_scheduler_current_app_id(); + ctx.argc = argc; + ctx.argv = argv; + + TaskEventGroup eventGroup {}; + task_event_group_construct(&eventGroup); + AppEventSubscription appEvents {}; + check(app_event_subscribe(&appEvents, &eventGroup) == ERROR_NONE); + + WindowId window = window_manager_create_ext(ctx.appInstanceId, createWidgets, destroyWidgets, &ctx); + + bool shouldClose = false; + while (!shouldClose) { + task_event_group_wait_any(&eventGroup, nullptr, portMAX_DELAY); + AppEvent event {}; + while (app_event_poll(&appEvents, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { + shouldClose = true; + break; + } + } + } + + window_manager_remove(window); + check(app_event_unsubscribe(&appEvents) == ERROR_NONE); + task_event_group_destruct(&eventGroup); + return 0; +} + +} // namespace + +AppInstanceId start(const std::string& text) { + const char* argv[] = { text.c_str() }; + AppInstanceId instanceId = 0; + AppStartContext context = app_start_context_for_manifest(&manifest); + app_start_context_set_arguments_ext(&context, 1, argv); + if (app_start_with_context(&context, &instanceId) != ERROR_NONE) { + return 0; + } + return instanceId; +} + +extern const ::AppManifest manifest = { + .id = "tactility.qrcode", + .name = "QR Code", + .category = APP_CATEGORY_SYSTEM, + .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, + .flags = APP_MANIFEST_FLAG_HIDDEN, + // The app task does no file operations, so its stack can be in external RAM + .stack = { .depth = 0, .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL } +}; + +} diff --git a/Tactility/Source/app/selectiondialog/SelectionDialog.cpp b/Tactility/Source/app/selectiondialog/SelectionDialog.cpp index ce403fc48..b028458a7 100644 --- a/Tactility/Source/app/selectiondialog/SelectionDialog.cpp +++ b/Tactility/Source/app/selectiondialog/SelectionDialog.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include @@ -178,7 +179,7 @@ extern const AppManifest manifest = { .category = APP_CATEGORY_SYSTEM, .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, .flags = APP_MANIFEST_FLAG_HIDDEN, - .stack = {} + .stack = { .depth = 3072, .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL }, }; } diff --git a/Tactility/Source/lvgl/SystemMenu.cpp b/Tactility/Source/lvgl/SystemMenu.cpp index 4136c2f6e..bac24a907 100644 --- a/Tactility/Source/lvgl/SystemMenu.cpp +++ b/Tactility/Source/lvgl/SystemMenu.cpp @@ -23,9 +23,7 @@ #include -#if TT_FEATURE_MESH #include -#endif #include #include @@ -68,14 +66,10 @@ static bool hasBluetooth() { /** Mesh can be toggled once it was configured: a backend is selected and the region is set */ static bool hasMesh() { -#if TT_FEATURE_MESH char backend[MESH_BACKEND_ID_MAX_LENGTH + 1]; return mesh_is_available() && mesh_get_backend(backend, sizeof(backend)) == ERROR_NONE && settings::radio::loadRegion() != RADIO_REGION_UNSET; -#else - return false; -#endif } static bool isWifiEnabled() { @@ -148,7 +142,6 @@ static void setBluetoothEnabled(void* context) { #endif -#if TT_FEATURE_MESH static bool isMeshEnabled() { return mesh_is_enabled(); @@ -161,7 +154,6 @@ static void setMeshEnabled(void* context) { } } -#endif // Changing the radio and device states can block, so it happens on the main task struct ToggleOption { @@ -290,13 +282,11 @@ static void createWidgets(lv_obj_t* parent, void* /*userData*/) { createOption(grid, &bluetooth_option); } #endif -#if TT_FEATURE_MESH // There is no "off" variant of this icon: the toggle's checked state shows whether mesh is on static constexpr ToggleOption mesh_option = { LVGL_ICON_SHARED_HUB, LVGL_ICON_SHARED_HUB, isMeshEnabled, setMeshEnabled }; if (hasMesh()) { createOption(grid, &mesh_option); } -#endif if (supportsPowerOff()) { auto* power_tile = createTile(grid, LVGL_ICON_SHARED_POWER_SETTINGS_NEW); lv_obj_add_event_cb(power_tile, onPowerOffClicked, LV_EVENT_SHORT_CLICKED, nullptr); diff --git a/Tactility/Source/service/espnow/EspNowBackendHosted.cpp b/Tactility/Source/service/espnow/EspNowBackendHosted.cpp index ca48054b2..86aab170d 100644 --- a/Tactility/Source/service/espnow/EspNowBackendHosted.cpp +++ b/Tactility/Source/service/espnow/EspNowBackendHosted.cpp @@ -16,6 +16,8 @@ extern "C" { } #include +#include + #include #include @@ -56,6 +58,7 @@ static std::atomic userRecvCallback{nullptr}; static uint32_t espnowVersion = 0; static std::atomic lastReportedInitVersion{0}; +static bool radioAcquired = false; static void onRespGeneric(uint32_t msgId, const uint8_t* data, size_t dataLen, void* ctx) { (void)msgId; (void)ctx; @@ -196,15 +199,37 @@ static bool registerCallbacksOnce() { return true; } +static void releaseRadio() { + if (radioAcquired) { + esp32_wifi_radio_release(WIFI_MODE_STA); + radioAcquired = false; + } +} + bool init(const EspNowConfig& config, esp_now_recv_cb_t recvCallback) { + if (radioAcquired) { + LOG_E(TAG, "Already initialized"); + return false; + } + + // The WiFi service starts the co-processor link the same way. Acquiring the shared radio serializes the two, + // so the link isn't reset again while the other one is still bringing it up. + if (!esp32_wifi_radio_acquire(WIFI_MODE_STA)) { + LOG_E(TAG, "Failed to acquire the WiFi radio"); + return false; + } + radioAcquired = true; + constexpr uint32_t HOSTED_TRANSPORT_WAIT_TIMEOUT_MS = 10000; if (!waitForHostedTransport(HOSTED_TRANSPORT_WAIT_TIMEOUT_MS)) { LOG_E(TAG, "esp_hosted transport didn't come up within %" PRIu32 "ms - is WiFi enabled?", HOSTED_TRANSPORT_WAIT_TIMEOUT_MS); + releaseRadio(); return false; } if (!registerCallbacksOnce()) { + releaseRadio(); return false; } @@ -241,6 +266,7 @@ bool init(const EspNowConfig& config, esp_now_recv_cb_t recvCallback) { if (err != ESP_OK) { LOG_E(TAG, "Remote esp_now_init() failed after %d attempts: %d", MAX_INIT_ATTEMPTS, err); userRecvCallback = nullptr; + releaseRadio(); return false; } @@ -259,6 +285,7 @@ bool deinit() { esp_err_t err = doRequest(ESPNOW_BRIDGE_REQ_DEINIT, reinterpret_cast(&req), sizeof(req)); userRecvCallback = nullptr; espnowVersion = 0; + releaseRadio(); if (err != ESP_OK) { LOG_E(TAG, "Remote esp_now_deinit() failed: %d", err); return false; diff --git a/Tactility/Source/service/espnow/EspNowBackendNative.cpp b/Tactility/Source/service/espnow/EspNowBackendNative.cpp index 60596f5b4..55ad4c411 100644 --- a/Tactility/Source/service/espnow/EspNowBackendNative.cpp +++ b/Tactility/Source/service/espnow/EspNowBackendNative.cpp @@ -10,13 +10,15 @@ #include #include +#include #include #include namespace tt::service::espnow::backend { constexpr auto* TAG = "EspNowBackendNative"; -static bool wifiStartedByEspNow = false; +static bool wifiAcquired = false; +static wifi_mode_t acquiredMode = WIFI_MODE_STA; static bool longRangeProtocolApplied = false; static wifi_interface_t longRangeInterface = WIFI_IF_STA; static uint8_t savedProtocolBitmap = 0; @@ -24,54 +26,30 @@ static uint8_t savedProtocolBitmap = 0; static bool deinitWifi(); static bool initWifi(const EspNowConfig& config) { - WifiRadioState radio_state = WIFI_RADIO_STATE_OFF; + if (wifiAcquired) { + LOG_E(TAG, "Already initialized"); + return false; + } + WifiStationState station_state = WIFI_STATION_STATE_DISCONNECTED; Device* wifi_device = nullptr; if (device_get_first_by_type(&WIFI_TYPE, &wifi_device) == ERROR_NONE) { - wifi_get_radio_state(wifi_device, &radio_state); wifi_get_station_state(wifi_device, &station_state); device_put(wifi_device); } - bool wifi_already_running = (radio_state != WIFI_RADIO_STATE_OFF && radio_state != WIFI_RADIO_STATE_OFF_PENDING); - wifi_mode_t mode; - if (config.mode == Mode::Station) { - mode = wifi_mode_t::WIFI_MODE_STA; - } else { - mode = wifi_mode_t::WIFI_MODE_AP; + const wifi_mode_t mode = (config.mode == Mode::Station) ? WIFI_MODE_STA : WIFI_MODE_AP; + + // The radio is shared with the WiFi service and the web server, so it only stops when the last of them released it + bool wifi_already_running = false; + if (!esp32_wifi_radio_acquire_ex(mode, &wifi_already_running)) { + LOG_E(TAG, "Failed to acquire the WiFi radio"); + return false; } + wifiAcquired = true; + acquiredMode = mode; - // Only initialize WiFi if it's not already running; ESP-NOW coexists with STA mode - wifiStartedByEspNow = !wifi_already_running; - if (wifiStartedByEspNow) { - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - if (esp_wifi_init(&cfg) != ESP_OK) { - LOG_E(TAG,"esp_wifi_init() failed"); - wifiStartedByEspNow = false; - return false; - } - - if (esp_wifi_set_storage(WIFI_STORAGE_RAM) != ESP_OK) { - LOG_E(TAG,"esp_wifi_set_storage() failed"); - esp_wifi_deinit(); - wifiStartedByEspNow = false; - return false; - } - - if (esp_wifi_set_mode(mode) != ESP_OK) { - LOG_E(TAG,"esp_wifi_set_mode() failed"); - esp_wifi_deinit(); - wifiStartedByEspNow = false; - return false; - } - - if (esp_wifi_start() != ESP_OK) { - LOG_E(TAG,"esp_wifi_start() failed"); - esp_wifi_deinit(); - wifiStartedByEspNow = false; - return false; - } - + if (!wifi_already_running) { if (config.channel != 0 && esp_wifi_set_channel(config.channel, WIFI_SECOND_CHAN_NONE) != ESP_OK) { LOG_E(TAG,"esp_wifi_set_channel() failed"); deinitWifi(); @@ -117,9 +95,7 @@ static bool initWifi(const EspNowConfig& config) { } static bool deinitWifi() { - // Restore the interface's protocol mask before either stopping WiFi or handing the radio - // back to WifiService - covers both paths, since an externally managed radio (wifiStartedByEspNow - // == false) is the case that actually needs its prior protocol bits put back. + // Restore the interface's protocol mask before releasing the radio, which other owners may keep using. if (longRangeProtocolApplied) { if (esp_wifi_set_protocol(longRangeInterface, savedProtocolBitmap) != ESP_OK) { LOG_W(TAG, "Failed to restore prior WiFi protocol bitmap"); @@ -127,13 +103,10 @@ static bool deinitWifi() { longRangeProtocolApplied = false; } - if (wifiStartedByEspNow) { - esp_wifi_stop(); - esp_wifi_deinit(); - wifiStartedByEspNow = false; - LOG_I(TAG, "WiFi stopped (was started by ESP-NOW)"); - } else { - LOG_I(TAG, "WiFi left running (managed by WiFi service)"); + if (wifiAcquired) { + esp32_wifi_radio_release(acquiredMode); + wifiAcquired = false; + LOG_I(TAG, "WiFi radio released"); } return true; } diff --git a/Tactility/Source/service/espnow/EspNowRadio.cpp b/Tactility/Source/service/espnow/EspNowRadio.cpp new file mode 100644 index 000000000..c824bed10 --- /dev/null +++ b/Tactility/Source/service/espnow/EspNowRadio.cpp @@ -0,0 +1,384 @@ +#ifdef ESP_PLATFORM +#include +#endif + +#if (defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED)) + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace tt::service::espnow { + +constexpr auto* TAG = "EspNowRadio"; + +namespace { + +constexpr uint32_t TX_BIT = (1 << 0); +constexpr uint32_t STOP_BIT = (1 << 1); +constexpr int32_t CHANNEL_MIN = 1; +constexpr int32_t CHANNEL_MAX = 14; +constexpr int32_t POWER_MIN = 2; +constexpr int32_t POWER_MAX = 20; +constexpr uint32_t ENABLE_TIMEOUT_MS = 5000; +constexpr uint8_t BROADCAST_ADDRESS[ESP_NOW_ETH_ALEN] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF }; + +struct Frame { + RadioTxId id; + tt::std::vector data; +}; + +struct Radio { + Device* device = nullptr; + Mutex mutex {}; + EventGroupHandle_t events = nullptr; + Thread* thread = nullptr; + RadioState state = RADIO_STATE_OFF; + RadioModulation modulation = RADIO_MODULATION_NONE; + /** Guarded by the mutex */ + int32_t channel = 1; + int32_t power = POWER_MAX; + RadioTxId nextTxId = 1; + tt::std::deque queue; + ReceiverSubscription subscription = NO_SUBSCRIPTION; + bool stopRequested = false; +}; + +Radio* getRadio(Device* device) { + return static_cast(device_get_driver_data(device)); +} + +void setState(Radio* radio, RadioState state) { + mutex_lock(&radio->mutex); + radio->state = state; + mutex_unlock(&radio->mutex); + radio_state_event_emit(radio->device, state); +} + +bool isStopRequested(Radio* radio) { + mutex_lock(&radio->mutex); + const bool stop = radio->stopRequested; + mutex_unlock(&radio->mutex); + return stop; +} + +/** Waits until the ESP-NOW service has brought ESP-NOW up. The service does that on another task. */ +bool waitUntilEnabled(Radio* radio) { + for (uint32_t waited = 0; waited < ENABLE_TIMEOUT_MS; waited += 20) { + if (isEnabled()) { + return true; + } + if (isStopRequested(radio)) { + return false; + } + delay_millis(20); + } + return isEnabled(); +} + +void onReceive(Radio* radio, const esp_now_recv_info_t* info, const uint8_t* data, int length) { + if (length <= 0) { + return; + } + const float rssi = (info != nullptr && info->rx_ctrl != nullptr) ? (float)info->rx_ctrl->rssi : 0.0f; + // Wi-Fi doesn't report the signal-to-noise ratio + radio_rx_event_emit(radio->device, data, ::std::min((size_t)length, RADIO_RX_MAX_PACKET_LENGTH), rssi, NAN); +} + +void transmitQueued(Radio* radio) { + while (!isStopRequested(radio)) { + mutex_lock(&radio->mutex); + if (radio->queue.empty()) { + mutex_unlock(&radio->mutex); + return; + } + Frame frame = ::std::move(radio->queue.front()); + radio->queue.pop_front(); + mutex_unlock(&radio->mutex); + + radio_tx_event_emit(radio->device, frame.id, RADIO_TRANSMISSION_STATE_TRANSMIT_PENDING); + const bool fits = frame.data.size() <= getMaxDataLength(); + // The frame is handed to the Wi-Fi stack, which doesn't report when it's out + const bool sent = fits && send(BROADCAST_ADDRESS, frame.data.data(), frame.data.size()); + if (!sent) { + LOG_W(TAG, "Failed to send %u bytes (the maximum is %u)", (unsigned)frame.data.size(), (unsigned)getMaxDataLength()); + } + radio_tx_event_emit(radio->device, frame.id, sent ? RADIO_TRANSMISSION_STATE_TRANSMITTED : RADIO_TRANSMISSION_STATE_ERROR); + } +} + +int32_t radioThread(void* context) { + auto* radio = static_cast(context); + + mutex_lock(&radio->mutex); + const int32_t channel = radio->channel; + const int32_t power = radio->power; + mutex_unlock(&radio->mutex); + + // The same settings as MeshCore's ESP-NOW transport: a station with the long range protocol, unencrypted broadcasts + uint8_t key[ESP_NOW_KEY_LEN] = {}; + enable(EspNowConfig(key, Mode::Station, (uint8_t)channel, true, false)); + + if (!waitUntilEnabled(radio)) { + // ESP-NOW may still finish starting on its own task, so it's turned off in both cases + disable(); + if (!isStopRequested(radio)) { + LOG_E(TAG, "ESP-NOW didn't start"); + setState(radio, RADIO_STATE_ERROR); + return 1; + } + setState(radio, RADIO_STATE_OFF); + return 0; + } +#ifdef CONFIG_SOC_WIFI_SUPPORTED + // In units of 0.25 dBm + esp_wifi_set_max_tx_power((int8_t)(power * 4)); +#endif + radio->subscription = subscribeReceiver([radio](const esp_now_recv_info_t* info, const uint8_t* data, int length) { + onReceive(radio, info, data, length); + }); + setState(radio, RADIO_STATE_ON); + + while (!isStopRequested(radio)) { + uint32_t flags = 0; + event_group_wait(radio->events, TX_BIT | STOP_BIT, false, true, &flags, portMAX_DELAY); + transmitQueued(radio); + } + + setState(radio, RADIO_STATE_OFF_PENDING); + unsubscribeReceiver(radio->subscription); + radio->subscription = NO_SUBSCRIPTION; + disable(); + setState(radio, RADIO_STATE_OFF); + return 0; +} + +// region RadioApi + +error_t apiGetState(Device* device, RadioState* state) { + auto* radio = getRadio(device); + mutex_lock(&radio->mutex); + *state = radio->state; + mutex_unlock(&radio->mutex); + return ERROR_NONE; +} + +void stopThread(Radio* radio) { + if (radio->thread == nullptr) { + return; + } + mutex_lock(&radio->mutex); + radio->stopRequested = true; + mutex_unlock(&radio->mutex); + event_group_set(radio->events, STOP_BIT); + thread_join(radio->thread, portMAX_DELAY, pdMS_TO_TICKS(10)); + thread_free(radio->thread); + radio->thread = nullptr; +} + +error_t apiSetEnabled(Device* device, bool enabled) { + auto* radio = getRadio(device); + if (enabled) { + if (radio->modulation != RADIO_MODULATION_ESPNOW) { + return ERROR_INVALID_STATE; + } + // A thread that ended by itself, e.g. after ESP-NOW failed to start, is replaced + RadioState state; + apiGetState(device, &state); + if (radio->thread != nullptr && (state == RADIO_STATE_ON || state == RADIO_STATE_ON_PENDING)) { + return ERROR_NONE; + } + stopThread(radio); + + mutex_lock(&radio->mutex); + radio->stopRequested = false; + radio->queue.clear(); + mutex_unlock(&radio->mutex); + event_group_clear(radio->events, TX_BIT | STOP_BIT); + setState(radio, RADIO_STATE_ON_PENDING); + radio->thread = thread_alloc_full("espnow_radio", 4096, radioThread, radio, -1); + if (radio->thread == nullptr || thread_start(radio->thread) != ERROR_NONE) { + if (radio->thread != nullptr) { + thread_free(radio->thread); + radio->thread = nullptr; + } + setState(radio, RADIO_STATE_ERROR); + return ERROR_RESOURCE; + } + return ERROR_NONE; + } + + stopThread(radio); + return ERROR_NONE; +} + +error_t apiSetModulation(Device* device, RadioModulation modulation) { + auto* radio = getRadio(device); + if (modulation != RADIO_MODULATION_ESPNOW) { + return ERROR_NOT_SUPPORTED; + } + RadioState state; + apiGetState(device, &state); + if (state == RADIO_STATE_ON || state == RADIO_STATE_ON_PENDING) { + return ERROR_INVALID_STATE; + } + radio->modulation = modulation; + return ERROR_NONE; +} + +error_t apiGetModulation(Device* device, RadioModulation* modulation) { + *modulation = getRadio(device)->modulation; + return ERROR_NONE; +} + +bool apiCanTransmitOrReceive(Device*, RadioModulation modulation) { + return modulation == RADIO_MODULATION_ESPNOW; +} + +error_t apiSetParameter(Device* device, RadioParameter parameter, int32_t value) { + auto* radio = getRadio(device); + switch (parameter) { + case RADIO_PARAMETER_CHANNEL: + if (value < CHANNEL_MIN || value > CHANNEL_MAX) { + return ERROR_OUT_OF_RANGE; + } + mutex_lock(&radio->mutex); + radio->channel = value; + mutex_unlock(&radio->mutex); + return ERROR_NONE; + case RADIO_PARAMETER_POWER: + if (value < POWER_MIN || value > POWER_MAX) { + return ERROR_OUT_OF_RANGE; + } + mutex_lock(&radio->mutex); + radio->power = value; + mutex_unlock(&radio->mutex); + return ERROR_NONE; + default: + return ERROR_NOT_SUPPORTED; + } +} + +error_t apiGetParameter(Device* device, RadioParameter parameter, int32_t* value) { + auto* radio = getRadio(device); + mutex_lock(&radio->mutex); + error_t result = ERROR_NONE; + if (parameter == RADIO_PARAMETER_CHANNEL) { + *value = radio->channel; + } else if (parameter == RADIO_PARAMETER_POWER) { + *value = radio->power; + } else { + result = ERROR_NOT_SUPPORTED; + } + mutex_unlock(&radio->mutex); + return result; +} + +error_t apiTransmit(Device* device, const uint8_t* data, size_t length, RadioTxId* id) { + auto* radio = getRadio(device); + mutex_lock(&radio->mutex); + if (radio->state != RADIO_STATE_ON && radio->state != RADIO_STATE_ON_PENDING) { + mutex_unlock(&radio->mutex); + return ERROR_INVALID_STATE; + } + const RadioTxId txId = radio->nextTxId++; + radio->queue.push_back(Frame { .id = txId, .data = tt::std::vector(data, data + length) }); + mutex_unlock(&radio->mutex); + + if (id != nullptr) { + *id = txId; + } + radio_tx_event_emit(device, txId, RADIO_TRANSMISSION_STATE_QUEUED); + event_group_set(radio->events, TX_BIT); + return ERROR_NONE; +} + +const RadioApi radioApi = { + .get_radio_state = apiGetState, + .set_enabled = apiSetEnabled, + .set_modulation = apiSetModulation, + .get_modulation = apiGetModulation, + .can_transmit = apiCanTransmitOrReceive, + .can_receive = apiCanTransmitOrReceive, + .set_parameter = apiSetParameter, + .get_parameter = apiGetParameter, + .transmit = apiTransmit, + .get_rssi = nullptr, + .is_receiving = nullptr, +}; + +// endregion + +error_t startDevice(Device* device) { + auto* radio = tt::newObject(); + if (radio == nullptr) { + return ERROR_OUT_OF_MEMORY; + } + radio->device = device; + mutex_construct(&radio->mutex); + event_group_construct(&radio->events); + device_set_driver_data(device, radio); + return ERROR_NONE; +} + +error_t stopDevice(Device* device) { + auto* radio = getRadio(device); + stopThread(radio); + device_set_driver_data(device, nullptr); + event_group_destruct(&radio->events); + mutex_destruct(&radio->mutex); + tt::deleteObject(radio); + return ERROR_NONE; +} + +Driver driver = { + .name = "espnow_radio", + .compatible = (const char*[]) { "tactility,espnow-radio", nullptr }, + .start_device = startDevice, + .stop_device = stopDevice, + .probe = nullptr, + .api = &radioApi, + .device_type = &RADIO_TYPE, + .internal = nullptr, +}; + +Device device { + .address = 0, + .name = "espnow-radio", + .config = nullptr, + .parent = nullptr, + .flags = 0, + .internal = nullptr, +}; + +} // namespace + +void registerRadio() { + if (driver_construct_add(&driver) != ERROR_NONE || + device_construct_add(&device, "tactility,espnow-radio") != ERROR_NONE || + device_start(&device) != ERROR_NONE) { + LOG_E(TAG, "Failed to add the ESP-NOW radio"); + } +} + +} + +#endif diff --git a/Tactility/Source/service/statusbar/Statusbar.cpp b/Tactility/Source/service/statusbar/Statusbar.cpp index 43f59a72e..d570d65bf 100644 --- a/Tactility/Source/service/statusbar/Statusbar.cpp +++ b/Tactility/Source/service/statusbar/Statusbar.cpp @@ -32,9 +32,7 @@ #include -#if TT_FEATURE_MESH #include -#endif namespace tt::service::statusbar { @@ -154,10 +152,8 @@ class StatusbarService final : public Service { std::unique_ptr updateTimer; int8_t gps_icon_id; const char* gps_last_icon = nullptr; -#if TT_FEATURE_MESH int8_t mesh_icon_id; bool mesh_last_state = false; -#endif int8_t bt_icon_id; const char* bt_last_icon = nullptr; int8_t wifi_icon_id; @@ -196,7 +192,6 @@ class StatusbarService final : public Service { } } -#if TT_FEATURE_MESH void updateMeshIcon() { const bool active = mesh_get_state() == MESH_STATE_ON; if (active != mesh_last_state) { @@ -204,7 +199,6 @@ class StatusbarService final : public Service { mesh_last_state = active; } } -#endif void updateBluetoothIcon() { auto radio_state = bluetooth::getRadioState(); @@ -334,9 +328,7 @@ class StatusbarService final : public Service { if (lvgl_is_running()) { if (lvgl_try_lock(200)) { updateGpsIcon(); -#if TT_FEATURE_MESH updateMeshIcon(); -#endif updateBluetoothIcon(); updateWifiIcon(); updatePowerStatusIcon(); @@ -353,9 +345,7 @@ public: gps_icon_id = lvgl_statusbar_icon_add(nullptr, false); // Added before the mesh icon, as the side strip hides the last added icons first when they don't fit install_icon_id = lvgl_statusbar_icon_add(LVGL_ICON_STATUSBAR_DOWNLOAD, false); -#if TT_FEATURE_MESH mesh_icon_id = lvgl_statusbar_icon_add(nullptr, false); -#endif bt_icon_id = lvgl_statusbar_icon_add(nullptr, false); usb_icon_id = lvgl_statusbar_icon_add(nullptr, false); wifi_icon_id = lvgl_statusbar_icon_add(nullptr, false); @@ -367,9 +357,7 @@ public: lvgl_statusbar_icon_remove(wifi_icon_id); lvgl_statusbar_icon_remove(usb_icon_id); lvgl_statusbar_icon_remove(bt_icon_id); -#if TT_FEATURE_MESH lvgl_statusbar_icon_remove(mesh_icon_id); -#endif lvgl_statusbar_icon_remove(install_icon_id); lvgl_statusbar_icon_remove(gps_icon_id); } @@ -384,10 +372,8 @@ public: lvgl_statusbar_icon_set_visibility(wifi_icon_id, true); lvgl_statusbar_icon_set_image(usb_icon_id, LVGL_ICON_STATUSBAR_USB); lvgl_statusbar_icon_set_visibility(usb_icon_id, false); -#if TT_FEATURE_MESH lvgl_statusbar_icon_set_image(mesh_icon_id, LVGL_ICON_STATUSBAR_HUB); lvgl_statusbar_icon_set_visibility(mesh_icon_id, false); -#endif task_event_group_construct(&appHubEventGroup); appHubSubscribed = app_hub_event_subscribe(&appHubSubscription, &appHubEventGroup) == ERROR_NONE; diff --git a/Tactility/Tests/CMakeLists.txt b/Tactility/Tests/CMakeLists.txt index e9bfd448c..f9b11ccba 100644 --- a/Tactility/Tests/CMakeLists.txt +++ b/Tactility/Tests/CMakeLists.txt @@ -55,6 +55,9 @@ target_link_libraries(TactilityTests PRIVATE http-module location-module mbedtls-module + mesh-module + mesh-meshcore-module + mesh-meshtastic-module posix-symbols-module pthread-module gps-generic-module diff --git a/TactilityKernel/include/tactility/drivers/radio.h b/TactilityKernel/include/tactility/drivers/radio.h index 5abaf343d..649f52f54 100644 --- a/TactilityKernel/include/tactility/drivers/radio.h +++ b/TactilityKernel/include/tactility/drivers/radio.h @@ -30,6 +30,11 @@ enum RadioModulation { RADIO_MODULATION_MSK, /** On-off keying (ASK) */ RADIO_MODULATION_OOK, + /** + * ESP-NOW over Wi-Fi in the 2.4 GHz band. Select the Wi-Fi channel with RADIO_PARAMETER_CHANNEL. + * The Wi-Fi stack applies the transmit power limits of the country, so regions don't restrict it. + */ + RADIO_MODULATION_ESPNOW, }; /** @@ -83,6 +88,8 @@ enum RadioParameter { RADIO_PARAMETER_LBT, /** Listen-before-talk RSSI threshold in dBm: the channel counts as busy at or above it */ RADIO_PARAMETER_LBT_RSSI_THRESHOLD, + /** Channel number, for radios that are tuned by channel rather than by frequency (ESP-NOW: Wi-Fi channel 1-14) */ + RADIO_PARAMETER_CHANNEL, }; enum RadioState { diff --git a/TactilityKernel/source/drivers/radio.cpp b/TactilityKernel/source/drivers/radio.cpp index c8247da0c..ade8b6584 100644 --- a/TactilityKernel/source/drivers/radio.cpp +++ b/TactilityKernel/source/drivers/radio.cpp @@ -120,6 +120,12 @@ static int8_t get_highest_power(enum RadioRegion region) { return highest; } +/** Regions limit the sub-GHz bands. ESP-NOW is limited by the Wi-Fi stack, which knows the country. */ +static bool is_limited_by_region(Device* device) { + enum RadioModulation modulation = RADIO_MODULATION_NONE; + return RADIO_API(device)->get_modulation(device, &modulation) != ERROR_NONE || modulation != RADIO_MODULATION_ESPNOW; +} + /** Check the configured frequency, bandwidth and power against a single band of the region */ static error_t check_configuration_in_region(Device* device, enum RadioRegion region) { int32_t frequency; @@ -446,7 +452,7 @@ error_t radio_get_state(struct Device* device, enum RadioState* state) { error_t radio_set_enabled(struct Device* device, bool enabled) { if (enabled) { const auto region = radio_get_region(device); - if (region != RADIO_REGION_UNSET) { + if (region != RADIO_REGION_UNSET && is_limited_by_region(device)) { const error_t result = check_configuration_in_region(device, region); if (result != ERROR_NONE) { return result; @@ -474,7 +480,7 @@ bool radio_can_receive(struct Device* device, enum RadioModulation modulation) { error_t radio_set_parameter(struct Device* device, enum RadioParameter parameter, int32_t value) { const auto region = radio_get_region(device); - if (region != RADIO_REGION_UNSET) { + if (region != RADIO_REGION_UNSET && is_limited_by_region(device)) { if (parameter == RADIO_PARAMETER_FREQUENCY && !is_in_any_band(region, value)) { return ERROR_OUT_OF_RANGE; } diff --git a/TactilityKernel/tests/source/radio_test.cpp b/TactilityKernel/tests/source/radio_test.cpp index 9ea0c6979..e6e158762 100644 --- a/TactilityKernel/tests/source/radio_test.cpp +++ b/TactilityKernel/tests/source/radio_test.cpp @@ -13,6 +13,7 @@ struct FakeRadio { int32_t frequency = 868000000; int32_t bandwidth = 125000; int32_t power = 14; + RadioModulation modulation = RADIO_MODULATION_LORA; }; FakeRadio fake_radio; @@ -27,10 +28,13 @@ error_t fake_set_enabled(Device*, bool enabled) { return ERROR_NONE; } -error_t fake_set_modulation(Device*, RadioModulation) { return ERROR_NONE; } +error_t fake_set_modulation(Device*, RadioModulation modulation) { + fake_radio.modulation = modulation; + return ERROR_NONE; +} error_t fake_get_modulation(Device*, RadioModulation* modulation) { - *modulation = RADIO_MODULATION_LORA; + *modulation = fake_radio.modulation; return ERROR_NONE; } @@ -203,6 +207,25 @@ TEST_CASE("radio_set_enabled checks the configuration against a single band") { CHECK_EQ(radio_set_enabled(device, false), ERROR_NONE); } +TEST_CASE("radio regions don't limit ESP-NOW") { + FakeRadioFixture fixture; + Device* device = &fixture.device; + + // The most restrictive region + CHECK_EQ(radio_set_region(device, RADIO_REGION_EU_433), ERROR_NONE); + CHECK_EQ(radio_set_parameter(device, RADIO_PARAMETER_POWER, 20), ERROR_OUT_OF_RANGE); + + // The Wi-Fi stack knows the limits of the country, and it doesn't use the bands of a region + CHECK_EQ(radio_set_modulation(device, RADIO_MODULATION_ESPNOW), ERROR_NONE); + CHECK_EQ(radio_set_parameter(device, RADIO_PARAMETER_POWER, 20), ERROR_NONE); + CHECK_EQ(radio_set_parameter(device, RADIO_PARAMETER_FREQUENCY, 100000000), ERROR_NONE); + CHECK_EQ(radio_set_enabled(device, true), ERROR_NONE); + CHECK_EQ(radio_set_enabled(device, false), ERROR_NONE); + + CHECK_EQ(radio_set_modulation(device, RADIO_MODULATION_LORA), ERROR_NONE); + CHECK_EQ(radio_set_enabled(device, true), ERROR_OUT_OF_RANGE); +} + TEST_CASE("radio_set_region rejects unknown regions") { FakeRadioFixture fixture; CHECK_EQ(radio_set_region(&fixture.device, RADIO_REGION_COUNT), ERROR_INVALID_ARGUMENT);