mirror of
https://github.com/ByteWelder/Tactility.git
synced 2026-10-17 19:05:07 +00:00
Mesh Chat apps, ESP-NOW mesh radio, and shared Wi-Fi radio ownership (#698)
Mesh gets contacts, channels, delivery receipts, QR sharing and an ESP-NOW radio. Mesh Chat is split into Mesh Contacts and Mesh Channels, `mesh inspect` replaces the Mesh Inspect app, and ESP-NOW and Wi-Fi now share the Wi-Fi radio safely.
This commit is contained in:
parent
76434d5bcd
commit
56ddd4b355
@ -9,6 +9,15 @@ Two kernel modules cover LVGL:
|
||||
- **`lvgl-window-manager-module`** (`Modules/lvgl-window-manager-module/`, `<lvgl_window_manager/*.h>`) 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.
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Binary file not shown.
@ -10,7 +10,6 @@ hardware.spiRamXipDisabled=true
|
||||
hardware.esptoolFlashFreq=80M
|
||||
hardware.bluetooth=true
|
||||
hardware.tinyUsbMsc=true
|
||||
hardware.mesh=true
|
||||
|
||||
storage.userDataLocation=SD
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -10,7 +10,6 @@ hardware.spiRamXipDisabled=true
|
||||
hardware.tinyUsbMsc=true
|
||||
hardware.esptoolFlashFreq=120M
|
||||
hardware.bluetooth=true
|
||||
hardware.mesh=true
|
||||
|
||||
storage.userDataLocation=SD
|
||||
|
||||
|
||||
@ -12,7 +12,6 @@ hardware.spiRamSpeed=80M
|
||||
hardware.tinyUsbMsc=true
|
||||
hardware.esptoolFlashFreq=80M
|
||||
hardware.bluetooth=true
|
||||
hardware.mesh=true
|
||||
|
||||
storage.userDataLocation=SD
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@ hardware.spiRamSpeed=120M
|
||||
hardware.tinyUsbMsc=true
|
||||
hardware.esptoolFlashFreq=40M
|
||||
hardware.bluetooth=true
|
||||
hardware.mesh=true
|
||||
|
||||
storage.userDataLocation=SD
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ hardware.spiRamSpeed=80M
|
||||
hardware.esptoolFlashFreq=80M
|
||||
hardware.tinyUsbMsc=true
|
||||
hardware.bluetooth=true
|
||||
hardware.mesh=true
|
||||
|
||||
storage.userDataLocation=SD
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -17,7 +17,7 @@ Module sx126x_module = {
|
||||
.stop = nullptr,
|
||||
.drivers = sx126x_drivers,
|
||||
.symbols = nullptr,
|
||||
.internal = nullptr
|
||||
.internal = nullptr,
|
||||
};
|
||||
|
||||
} // extern "C"
|
||||
|
||||
@ -52,6 +52,7 @@ list(APPEND REQUIRES_LIST
|
||||
binfont-module
|
||||
lodepng-module
|
||||
lvgl
|
||||
QRCode
|
||||
)
|
||||
|
||||
if (DEFINED ENV{ESP_IDF_VERSION})
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
40
Modules/lvgl-module/include/lvgl/widgets/qr.h
Normal file
40
Modules/lvgl-module/include/lvgl/widgets/qr.h
Normal file
@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#pragma once
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
#include <tactility/error.h>
|
||||
|
||||
#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
|
||||
@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
#ifdef ESP_PLATFORM
|
||||
|
||||
#include <esp_heap_caps.h>
|
||||
#include <esp_memory_utils.h>
|
||||
#include <esp_lvgl_port.h>
|
||||
|
||||
#include <lvgl/lvgl.h>
|
||||
@ -11,6 +13,12 @@
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/time.h>
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -43,6 +43,7 @@ const char* const lvgl_icon_shared_names[] = {
|
||||
"music_note",
|
||||
"note_add",
|
||||
"palette",
|
||||
"person_remove",
|
||||
"power_settings_new",
|
||||
"refresh",
|
||||
"search",
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
#include <lvgl/widgets/list.h>
|
||||
#include <lvgl/widgets/page_indicator.h>
|
||||
#include <lvgl/widgets/step_indicator.h>
|
||||
#include <lvgl/widgets/qr.h>
|
||||
#include <lvgl/widgets/spinner.h>
|
||||
#include <lvgl/widgets/statusbar.h>
|
||||
#include <lvgl/widgets/text_button.h>
|
||||
@ -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
|
||||
|
||||
178
Modules/lvgl-module/source/widgets/qr.cpp
Normal file
178
Modules/lvgl-module/source/widgets/qr.cpp
Normal file
@ -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 <lvgl/widgets/qr.h>
|
||||
|
||||
#include <tactility/memory.h>
|
||||
|
||||
extern "C" {
|
||||
#include <qrcode.h>
|
||||
}
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
// 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<uint8_t*>(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<QrView*>(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<QrView*>(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<QrView*>(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<uint8_t>(x), static_cast<uint8_t>(y))) {
|
||||
x++;
|
||||
continue;
|
||||
}
|
||||
int32_t end = x + 1;
|
||||
while (end < view->code.size && qrcode_getModule(&view->code, static_cast<uint8_t>(end), static_cast<uint8_t>(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<QrView*>(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<uint8_t*>(const_cast<char*>(data)), static_cast<uint16_t>(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<QrView*>(obj);
|
||||
view->dark = dark;
|
||||
view->light = light;
|
||||
lv_obj_invalidate(obj);
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
25
Modules/lvgl-module/tests/source/qr_test.cpp
Normal file
25
Modules/lvgl-module/tests/source/qr_test.cpp
Normal file
@ -0,0 +1,25 @@
|
||||
#include "doctest.h"
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/widgets/qr.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
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();
|
||||
}
|
||||
@ -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<BuildRequest*>(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) {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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=<name>&public_key=<hex>&type=<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]);
|
||||
|
||||
|
||||
@ -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<Backend*>(context));
|
||||
}
|
||||
|
||||
error_t getContactUri(void* context, const MeshAddress* contact, const char* name, char* uri, size_t uriSize) {
|
||||
auto* backend = static_cast<Backend*>(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<Backend*>(context));
|
||||
}
|
||||
|
||||
bool onReceive(void* context, const uint8_t* data, size_t length, float rssi, float snr) {
|
||||
auto* backend = static_cast<Backend*>(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<Backend*>(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,
|
||||
};
|
||||
|
||||
@ -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<uint8_t>(*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;
|
||||
}
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
#include <mesh_meshcore/meshcore_packet.h>
|
||||
#include <mesh_meshcore/meshcore_protocol.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@ -341,8 +341,11 @@ void destroy(void* context) {
|
||||
tt::deleteObject(static_cast<Backend*>(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<Backend*>(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<Backend*>(context));
|
||||
}
|
||||
|
||||
bool isValidContactAddress(void*, const MeshAddress* address) {
|
||||
return address->length == 4;
|
||||
}
|
||||
|
||||
void onNodeNameChanged(void* context) {
|
||||
sendNodeInfo(static_cast<Backend*>(context));
|
||||
}
|
||||
|
||||
bool onReceive(void* context, const uint8_t* frame, size_t length, float rssi, float snr) {
|
||||
auto* backend = static_cast<Backend*>(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<Backend*>(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,
|
||||
};
|
||||
|
||||
@ -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()`.
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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<uint32_t> findOutgoing(const char* backend, enum MeshDeliveryState delivery) const;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -2,16 +2,31 @@
|
||||
#include <mesh/mesh.h>
|
||||
#include <mesh/mesh_service.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/io.h>
|
||||
#include <app/manifest.h>
|
||||
|
||||
#include <TactilityCpp/Memory.h>
|
||||
#include <TactilityCpp/std/map.h>
|
||||
#include <TactilityCpp/std/vector.h>
|
||||
|
||||
#include <tactility/concurrent/task_event_group.h>
|
||||
|
||||
#include <tactility/drivers/radio.h>
|
||||
#include <tactility/radio_settings.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <csignal>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <unistd.h>
|
||||
|
||||
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 [<name>]\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<uint8_t, MeshChannel>& 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<uint8_t, MeshChannel>& 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<uint8_t, MeshChannel>& channels, uint32_t seq) {
|
||||
auto* message = tt::newObject<MeshMessage>();
|
||||
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<uint8_t, MeshChannel>& channels, size_t amount) {
|
||||
tt::std::vector<MeshMessage> 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<size_t>(value, INSPECT_MAX_AMOUNT);
|
||||
}
|
||||
|
||||
if (!follow) {
|
||||
tt::std::map<uint8_t, MeshChannel> 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<uint8_t, MeshChannel> 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<void*>(mesh_command_main) },
|
||||
.flags = APP_MANIFEST_FLAG_HEADLESS,
|
||||
.stack = { .depth = 5120, .desired_memory_capability = 0 },
|
||||
.stack = { .depth = 6144, .desired_memory_capability = 0 },
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@ -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<Search*>(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<Device**>(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<MeshNode> nodes;
|
||||
/** The saved contacts of every backend */
|
||||
tt::std::vector<MeshContact> contacts;
|
||||
char activeBackend[MESH_BACKEND_ID_MAX_LENGTH + 1] = {};
|
||||
tt::std::deque<std::function<void()>> 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<void(MeshHost*)>& 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<size_t*>(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<Search*>(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<Search*>(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
|
||||
|
||||
@ -11,18 +11,32 @@
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <esp_random.h>
|
||||
#else
|
||||
#include <random>
|
||||
#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);
|
||||
}
|
||||
|
||||
@ -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<uint8_t*>(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) {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
#include <tactility/filesystem/file_system.h>
|
||||
#include <tactility/paths.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
@ -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<int> 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);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
|
||||
#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<std::mutex> 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<std::mutex> 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<wifi_mode_t>(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<wifi_mode_t>(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<wifi_mode_t>(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 },
|
||||
|
||||
@ -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}"
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
72
Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h
Normal file
72
Tactility/Include/Tactility/app/meshchat/MeshChatCommon.h
Normal file
@ -0,0 +1,72 @@
|
||||
#pragma once
|
||||
|
||||
|
||||
#include <mesh/mesh.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
33
Tactility/Include/Tactility/app/meshsettings/MeshChannels.h
Normal file
33
Tactility/Include/Tactility/app/meshsettings/MeshChannels.h
Normal file
@ -0,0 +1,33 @@
|
||||
#pragma once
|
||||
|
||||
|
||||
#include <tactility/error.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
18
Tactility/Include/Tactility/app/qrcode/QrCode.h
Normal file
18
Tactility/Include/Tactility/app/qrcode/QrCode.h
Normal file
@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "app/instance.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
}
|
||||
@ -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);
|
||||
|
||||
19
Tactility/Private/Tactility/service/espnow/EspNowRadio.h
Normal file
19
Tactility/Private/Tactility/service/espnow/EspNowRadio.h
Normal file
@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#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
|
||||
@ -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);
|
||||
|
||||
@ -6,17 +6,16 @@
|
||||
|
||||
#include <Tactility/service/ServiceManifest.h>
|
||||
#include <Tactility/service/ServiceRegistration.h>
|
||||
#include <Tactility/service/espnow/EspNowRadio.h>
|
||||
#include <Tactility/settings/RadioSettings.h>
|
||||
|
||||
#include <location/module.h>
|
||||
#include <app_hub/module.h>
|
||||
#include <development/module.h>
|
||||
#include <wifi/module.h>
|
||||
#if TT_FEATURE_MESH
|
||||
#include <mesh/module.h>
|
||||
#include <mesh_meshcore/module.h>
|
||||
#include <mesh_meshtastic/module.h>
|
||||
#endif
|
||||
|
||||
#if defined(__linux__) || defined(__APPLE__)
|
||||
#include <tactility/posix_services.h>
|
||||
@ -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)) {
|
||||
|
||||
@ -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<uint32_t>(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<uint32_t>(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<uint32_t>(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;
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/memory.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/devices/indev.h>
|
||||
@ -176,9 +177,9 @@ extern const ::AppManifest manifest = {
|
||||
.id = "tactility.alertdialog",
|
||||
.name = "Alert Dialog",
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
.stack = {}
|
||||
.stack = { .depth = 3072, .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL },
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@ -27,6 +27,8 @@ constexpr auto text_file_extensions = std::to_array<std::string_view>({
|
||||
".html", ".htm",
|
||||
".css", ".scss", ".sass", ".less",
|
||||
".xml", ".svg",
|
||||
|
||||
".codepoints",
|
||||
});
|
||||
|
||||
bool isSupportedAppFile(const std::string& filename) {
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/error.h>
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/memory.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
|
||||
@ -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<ButtonContext*>(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<Context*>(lv_event_get_user_data(e)), lv_event_get_target_obj(e));
|
||||
}
|
||||
|
||||
void onButtonPressed(lv_event_t* e) {
|
||||
auto* btnCtx = static_cast<ButtonContext*>(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<void*>(appMain) },
|
||||
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
.stack = {}
|
||||
.stack = { .depth = 3072, .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL },
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
#include <app/event.h>
|
||||
#include <app/location.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/scheduler.h>
|
||||
#include <app/start.h>
|
||||
@ -6,13 +7,13 @@
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/theme.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/memory.h>
|
||||
|
||||
#include <Tactility/app/TileGrid.h>
|
||||
#include <Tactility/app/launcher/LauncherMode.h>
|
||||
@ -206,7 +207,7 @@ extern const ::AppManifest manifest = {
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
.stack = { .depth = 5120, .desired_memory_capability = 0 }
|
||||
.stack = { .depth = 5120, .desired_memory_capability = 0 },
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
279
Tactility/Source/app/meshchat/MeshAddContact.cpp
Normal file
279
Tactility/Source/app/meshchat/MeshAddContact.cpp
Normal file
@ -0,0 +1,279 @@
|
||||
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/app/meshchat/MeshChatCommon.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/scheduler.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <TactilityCpp/std/vector.h>
|
||||
|
||||
#include <mesh/mesh.h>
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/concurrent/task_event_group.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <lvgl/fonts.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/card.h>
|
||||
#include <lvgl/widgets/list.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
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<int32_t> selectedIndex = 0;
|
||||
/** The nodes that aren't contacts yet */
|
||||
tt::std::vector<MeshNode> 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<MeshContact> 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<Context*>(lv_event_get_user_data(event));
|
||||
ctx->selectedIndex = (int32_t)reinterpret_cast<intptr_t>(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<Context*>(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<void*>(static_cast<intptr_t>(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<Context*>(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<Context*>(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<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
.stack = {}
|
||||
};
|
||||
|
||||
} // namespace tt::app::meshchat
|
||||
|
||||
430
Tactility/Source/app/meshchat/MeshChannels.cpp
Normal file
430
Tactility/Source/app/meshchat/MeshChannels.cpp
Normal file
@ -0,0 +1,430 @@
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/app/inputdialog/InputDialog.h>
|
||||
#include <Tactility/app/meshchat/MeshChatCommon.h>
|
||||
#include <Tactility/app/meshsettings/MeshChannels.h>
|
||||
#include <Tactility/app/selectiondialog/SelectionDialog.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/scheduler.h>
|
||||
#include <app/stream.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <TactilityCpp/std/vector.h>
|
||||
|
||||
#include <mesh/mesh.h>
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/concurrent/task_event_group.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/list.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
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<int32_t> 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<MeshChannel> 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<void*>(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<Context*>(lv_event_get_user_data(event));
|
||||
ctx->openIndex = (int32_t)reinterpret_cast<intptr_t>(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<Context*>(lv_event_get_user_data(event));
|
||||
task_event_group_signal(ctx->eventGroup, ctx->addChannelBit);
|
||||
}
|
||||
|
||||
void onSettingsPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
task_event_group_signal(ctx->eventGroup, ctx->settingsBit);
|
||||
}
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(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<Context*>(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<Context*>(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<AddType>(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<void*>(appMain) },
|
||||
.flags = 0,
|
||||
.stack = { .depth = 6144, .desired_memory_capability = 0 },
|
||||
};
|
||||
|
||||
} // namespace tt::app::meshchat
|
||||
|
||||
52
Tactility/Source/app/meshchat/MeshChatCommon.cpp
Normal file
52
Tactility/Source/app/meshchat/MeshChatCommon.cpp
Normal file
@ -0,0 +1,52 @@
|
||||
#include <Tactility/app/meshchat/MeshChatCommon.h>
|
||||
|
||||
#include <app/start.h>
|
||||
|
||||
#include <lvgl/widgets/card.h>
|
||||
|
||||
#include <tactility/log.h>
|
||||
|
||||
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<void*>(static_cast<intptr_t>(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<int32_t>(reinterpret_cast<intptr_t>(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;
|
||||
}
|
||||
|
||||
}
|
||||
368
Tactility/Source/app/meshchat/MeshContacts.cpp
Normal file
368
Tactility/Source/app/meshchat/MeshContacts.cpp
Normal file
@ -0,0 +1,368 @@
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/app/inputdialog/InputDialog.h>
|
||||
#include <Tactility/app/meshchat/MeshChatCommon.h>
|
||||
#include <Tactility/app/qrcode/QrCode.h>
|
||||
#include <Tactility/app/selectiondialog/SelectionDialog.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/scheduler.h>
|
||||
#include <app/stream.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <TactilityCpp/std/vector.h>
|
||||
|
||||
#include <mesh/mesh.h>
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/concurrent/task_event_group.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <lvgl/fonts.h>
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/list.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
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<int32_t> 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<MeshContact> 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<void*>(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<Context*>(lv_event_get_user_data(event));
|
||||
auto* button = lv_event_get_target_obj(event);
|
||||
ctx->openIndex = (int32_t)reinterpret_cast<intptr_t>(lv_obj_get_user_data(button));
|
||||
task_event_group_signal(ctx->eventGroup, ctx->openBit);
|
||||
}
|
||||
|
||||
void onMenuPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
task_event_group_signal(ctx->eventGroup, ctx->menuBit);
|
||||
}
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(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<Context*>(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<Context*>(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<MenuItem>(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<void*>(appMain) },
|
||||
.flags = 0,
|
||||
.stack = { .depth = 6144, .desired_memory_capability = 0 },
|
||||
};
|
||||
|
||||
} // namespace tt::app::meshchat
|
||||
|
||||
486
Tactility/Source/app/meshchat/MeshConversation.cpp
Normal file
486
Tactility/Source/app/meshchat/MeshConversation.cpp
Normal file
@ -0,0 +1,486 @@
|
||||
|
||||
#include <Tactility/app/alertdialog/AlertDialog.h>
|
||||
#include <Tactility/app/meshchat/MeshChatCommon.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/scheduler.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <TactilityCpp/std/deque.h>
|
||||
#include <TactilityCpp/std/vector.h>
|
||||
|
||||
#include <mesh/mesh.h>
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/concurrent/task_event_group.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <lvgl/fonts.h>
|
||||
#include <lvgl/icons/shared.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
|
||||
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<MeshMessage> 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<void*>(static_cast<uintptr_t>(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<Context*>(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<Context*>(lv_event_get_user_data(event));
|
||||
task_event_group_signal(ctx->eventGroup, ctx->removeBit);
|
||||
}
|
||||
|
||||
void onInfoPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
task_event_group_signal(ctx->eventGroup, ctx->infoBit);
|
||||
}
|
||||
|
||||
void onBackPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(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<Context*>(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<Context*>(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<MeshMessage> 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<uint32_t>(reinterpret_cast<uintptr_t>(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<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
.stack = { .depth = 5120, .desired_memory_capability = 0 },
|
||||
};
|
||||
|
||||
} // namespace tt::app::meshchat
|
||||
|
||||
@ -1,534 +0,0 @@
|
||||
#if TT_FEATURE_MESH
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/scheduler.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <TactilityCpp/std/deque.h>
|
||||
#include <TactilityCpp/std/map.h>
|
||||
#include <TactilityCpp/std/string.h>
|
||||
#include <TactilityCpp/std/vector.h>
|
||||
|
||||
#include <mesh/mesh.h>
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/concurrent/task_event_group.h>
|
||||
#include <tactility/concurrent/thread.h>
|
||||
|
||||
#include <lvgl/fonts.h>
|
||||
#include <lvgl/grid_navigation.h>
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/focus_container.h>
|
||||
#include <lvgl/widgets/toolbar.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
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<Entry> entries;
|
||||
/** Channel names by channel index */
|
||||
tt::std::map<uint8_t, tt::std::string> 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<uint32_t, MeshDeliveryState> pendingDeliveries;
|
||||
TaskEventGroup* eventGroup = nullptr;
|
||||
uint32_t loadedBit = 0;
|
||||
Thread* loadThread = nullptr;
|
||||
::std::atomic<bool> loadCancelled = false;
|
||||
// Written by the load thread until it signals loadedBit
|
||||
tt::std::deque<Entry> loadedEntries;
|
||||
tt::std::map<uint8_t, tt::std::string> 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<void*>(static_cast<uintptr_t>(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<uint32_t>(reinterpret_cast<uintptr_t>(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<Context*>(lv_event_get_user_data(event));
|
||||
app_event_emit_close(ctx->appInstanceId);
|
||||
}
|
||||
|
||||
void createWidgets(lv_obj_t* parent, void* userData) {
|
||||
auto* ctx = static_cast<Context*>(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<Context*>(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<uint8_t, tt::std::string>& 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<Entry>& entries) {
|
||||
tt::std::vector<MeshMessage> 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*>(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<void*>(appMain) },
|
||||
.flags = 0,
|
||||
.stack = {}
|
||||
};
|
||||
|
||||
} // namespace tt::app::meshinspect
|
||||
|
||||
#endif
|
||||
@ -1,6 +1,12 @@
|
||||
#if TT_FEATURE_MESH
|
||||
#include "app/location.h"
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <esp_random.h>
|
||||
#else
|
||||
#include <random>
|
||||
#endif
|
||||
|
||||
#include <Tactility/app/inputdialog/InputDialog.h>
|
||||
#include <Tactility/app/meshsettings/MeshChannels.h>
|
||||
#include <Tactility/app/selectiondialog/SelectionDialog.h>
|
||||
#include <Tactility/settings/RadioSettings.h>
|
||||
|
||||
@ -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<std::string> serviceIds;
|
||||
std::vector<std::string> presetNames;
|
||||
/** Device names of the radios, in the order of the selection dialog */
|
||||
std::vector<std::string> 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<std::string>& names, std::vector<std::string>& labels) {
|
||||
names.clear();
|
||||
labels.clear();
|
||||
std::vector<MeshRadioKind> 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<std::string> radioNames;
|
||||
std::vector<std::string> 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<Context*>(lv_event_get_user_data(event));
|
||||
task_event_group_signal(ctx->eventGroup, ctx->addChannelBit);
|
||||
}
|
||||
|
||||
void onSelectRadioPressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
|
||||
task_event_group_signal(ctx->eventGroup, ctx->selectRadioBit);
|
||||
}
|
||||
|
||||
void onChangeNamePressed(lv_event_t* event) {
|
||||
auto* ctx = static_cast<Context*>(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<uint8_t>(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<std::string> 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<int32_t>(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<Context*>(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<uint8_t>(*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<uint8_t>(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<uint8_t>(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<void*>(appMain) },
|
||||
.location = { .type = APP_LOCATION_MEMORY, .location = reinterpret_cast<void*>(appMain) },
|
||||
.flags = 0,
|
||||
.stack = {}
|
||||
.stack = { .depth = 6144, .desired_memory_capability = 0 },
|
||||
};
|
||||
|
||||
} // namespace tt::app::meshsettings
|
||||
|
||||
#endif
|
||||
|
||||
129
Tactility/Source/app/qrcode/QrCode.cpp
Normal file
129
Tactility/Source/app/qrcode/QrCode.cpp
Normal file
@ -0,0 +1,129 @@
|
||||
#include <Tactility/app/qrcode/QrCode.h>
|
||||
|
||||
#include <app/event.h>
|
||||
#include <app/manager.h>
|
||||
#include <app/manifest.h>
|
||||
#include <app/scheduler.h>
|
||||
#include <app/start.h>
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/memory.h>
|
||||
|
||||
#include <lvgl/lvgl.h>
|
||||
#include <lvgl/widgets/qr.h>
|
||||
|
||||
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<Context*>(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<Context*>(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<Context*>(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<void*>(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 }
|
||||
};
|
||||
|
||||
}
|
||||
@ -10,6 +10,7 @@
|
||||
|
||||
#include <tactility/check.h>
|
||||
#include <tactility/log.h>
|
||||
#include <tactility/memory.h>
|
||||
|
||||
#include <lvgl.h>
|
||||
#include <lvgl/widgets/card.h>
|
||||
@ -178,7 +179,7 @@ extern const AppManifest manifest = {
|
||||
.category = APP_CATEGORY_SYSTEM,
|
||||
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
|
||||
.flags = APP_MANIFEST_FLAG_HIDDEN,
|
||||
.stack = {}
|
||||
.stack = { .depth = 3072, .desired_memory_capability = MEMORY_CAPABILITY_EXTERNAL },
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
@ -23,9 +23,7 @@
|
||||
|
||||
#include <lvgl_window_manager/window_manager.h>
|
||||
|
||||
#if TT_FEATURE_MESH
|
||||
#include <mesh/mesh.h>
|
||||
#endif
|
||||
|
||||
#include <tactility/concurrent/dispatcher.h>
|
||||
#include <tactility/device.h>
|
||||
@ -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);
|
||||
|
||||
@ -16,6 +16,8 @@ extern "C" {
|
||||
}
|
||||
#include <esp_now.h>
|
||||
|
||||
#include <tactility/drivers/esp32_wifi_radio.h>
|
||||
|
||||
#include <Tactility/Semaphore.h>
|
||||
#include <Tactility/RecursiveMutex.h>
|
||||
|
||||
@ -56,6 +58,7 @@ static std::atomic<esp_now_recv_cb_t> userRecvCallback{nullptr};
|
||||
static uint32_t espnowVersion = 0;
|
||||
|
||||
static std::atomic<uint32_t> 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<uint8_t*>(&req), sizeof(req));
|
||||
userRecvCallback = nullptr;
|
||||
espnowVersion = 0;
|
||||
releaseRadio();
|
||||
if (err != ESP_OK) {
|
||||
LOG_E(TAG, "Remote esp_now_deinit() failed: %d", err);
|
||||
return false;
|
||||
|
||||
@ -10,13 +10,15 @@
|
||||
#include <esp_wifi.h>
|
||||
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/drivers/esp32_wifi_radio.h>
|
||||
#include <tactility/drivers/wifi.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
384
Tactility/Source/service/espnow/EspNowRadio.cpp
Normal file
384
Tactility/Source/service/espnow/EspNowRadio.cpp
Normal file
@ -0,0 +1,384 @@
|
||||
#ifdef ESP_PLATFORM
|
||||
#include <sdkconfig.h>
|
||||
#endif
|
||||
|
||||
#if (defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_ESP_HOSTED_ENABLED))
|
||||
|
||||
#include <Tactility/service/espnow/EspNow.h>
|
||||
#include <Tactility/service/espnow/EspNowRadio.h>
|
||||
|
||||
#include <TactilityCpp/Memory.h>
|
||||
#include <TactilityCpp/std/deque.h>
|
||||
#include <TactilityCpp/std/vector.h>
|
||||
|
||||
#include <tactility/concurrent/event_group.h>
|
||||
#include <tactility/concurrent/mutex.h>
|
||||
#include <tactility/concurrent/thread.h>
|
||||
#include <tactility/delay.h>
|
||||
#include <tactility/device.h>
|
||||
#include <tactility/driver.h>
|
||||
#include <tactility/drivers/radio.h>
|
||||
#include <tactility/log.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
#include <esp_wifi.h>
|
||||
|
||||
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<uint8_t> 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<Frame> queue;
|
||||
ReceiverSubscription subscription = NO_SUBSCRIPTION;
|
||||
bool stopRequested = false;
|
||||
};
|
||||
|
||||
Radio* getRadio(Device* device) {
|
||||
return static_cast<Radio*>(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>((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<Radio*>(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<uint8_t>(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<Radio>();
|
||||
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
|
||||
@ -32,9 +32,7 @@
|
||||
|
||||
#include <location/location.h>
|
||||
|
||||
#if TT_FEATURE_MESH
|
||||
#include <mesh/mesh.h>
|
||||
#endif
|
||||
|
||||
namespace tt::service::statusbar {
|
||||
|
||||
@ -154,10 +152,8 @@ class StatusbarService final : public Service {
|
||||
std::unique_ptr<Timer> 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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user