diff --git a/Documentation/ideas.md b/Documentation/ideas.md index b700f6d67..b22fa8ef9 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -7,7 +7,6 @@ ## Higher Priority -- Make tt.py work without an app project. Ping the device for info. - LVGL spinner not working on cl32 v04 (possibly anything monochrome) - Find out why the first build of an app with a clean build cache folder always fails - Improve posix support: use user home path and system /tmp folder. diff --git a/Drivers/gps-meshtastic-module/source/gps_meshtastic.cpp b/Drivers/gps-meshtastic-module/source/gps_meshtastic.cpp index 8512b5360..4a6809c0d 100644 --- a/Drivers/gps-meshtastic-module/source/gps_meshtastic.cpp +++ b/Drivers/gps-meshtastic-module/source/gps_meshtastic.cpp @@ -196,6 +196,24 @@ static int32_t gps_thread_main(void* context) { } break; } + case MINMEA_SENTENCE_GSV: { + GpsEvent event { .type = GPS_EVENT_MESSAGE_GSV, .data = {} }; + if (minmea_parse_gsv(&event.data.gsv, reinterpret_cast(buffer))) { + notify_subscribers(internal, event); + } else { + LOG_E(TAG, "GSV parse error: %s", reinterpret_cast(buffer)); + } + break; + } + case MINMEA_SENTENCE_GSA: { + GpsEvent event { .type = GPS_EVENT_MESSAGE_GSA, .data = {} }; + if (minmea_parse_gsa(&event.data.gsa, reinterpret_cast(buffer))) { + notify_subscribers(internal, event); + } else { + LOG_E(TAG, "GSA parse error: %s", reinterpret_cast(buffer)); + } + break; + } default: break; } diff --git a/Modules/app-hub-module/include/app_hub/app_hub.h b/Modules/app-hub-module/include/app_hub/app_hub.h index 193568d8c..4a6b4779e 100644 --- a/Modules/app-hub-module/include/app_hub/app_hub.h +++ b/Modules/app-hub-module/include/app_hub/app_hub.h @@ -16,7 +16,7 @@ extern "C" { /** * App Hub service: finds, installs and removes apps from the online app catalog. - * Operations are queued and run one at a time on a background task. Their outcome is reported through events. + * Operations are queued and run one at a time, in order, on a background task. Their outcome is reported through events. */ #define APP_HUB_NAME_MAX_LENGTH 63 @@ -103,10 +103,10 @@ error_t app_hub_refresh(uint32_t* operation_id); /** * Queue the installation of a catalog app. An installed version is replaced. - * Only one install or remove is queued or running at a time. A refresh can be pending next to it. + * Installs and removes of different apps can be queued together. Per app, only one is queued or running at a time. * @param[out] operation_id the id of the queued operation (optional) * @retval ERROR_NONE the operation was queued, or the same install was already pending and its id is returned - * @retval ERROR_RESOURCE_BUSY another install or remove is pending + * @retval ERROR_RESOURCE_BUSY a remove of this app or an update of all apps is pending */ error_t app_hub_install(const char* id, uint32_t* operation_id); @@ -114,7 +114,7 @@ error_t app_hub_install(const char* id, uint32_t* operation_id); * Queue the update of every installed app that has a newer version in the catalog. The apps are installed one at a time, * with an INSTALLING and INSTALLED event each. A failed update doesn't stop the updates of the other apps. * While the operation is pending, the apps with an update report it as their pending operation. - * The operation takes the place of an install or remove: only one of them is queued or running at a time. + * It can't be queued next to an install or remove. * @param[out] operation_id the id of the queued operation (optional) * @retval ERROR_NONE the operation was queued, or it was already pending and its id is returned * @retval ERROR_RESOURCE_BUSY an install or remove is pending @@ -123,10 +123,10 @@ error_t app_hub_update_all(uint32_t* operation_id); /** * Queue the removal of an installed app. - * Only one install or remove is queued or running at a time. A refresh can be pending next to it. + * Installs and removes of different apps can be queued together. Per app, only one is queued or running at a time. * @param[out] operation_id the id of the queued operation (optional) * @retval ERROR_NONE the operation was queued, or the same removal was already pending and its id is returned - * @retval ERROR_RESOURCE_BUSY another install or remove is pending + * @retval ERROR_RESOURCE_BUSY an install of this app or an update of all apps is pending */ error_t app_hub_remove(const char* id, uint32_t* operation_id); diff --git a/Modules/app-hub-module/source/app_hub_command.cpp b/Modules/app-hub-module/source/app_hub_command.cpp index 41470eb1b..d5ff37d14 100644 --- a/Modules/app-hub-module/source/app_hub_command.cpp +++ b/Modules/app-hub-module/source/app_hub_command.cpp @@ -30,7 +30,7 @@ static void print_usage() { static int report_error(const char* action, error_t error) { if (error == ERROR_RESOURCE_BUSY) { - printf("apphub: %s failed: another app is being installed or removed\n", action); + printf("apphub: %s failed: a conflicting operation is pending\n", action); } else { printf("apphub: %s failed: %s\n", action, error_to_string(error)); } diff --git a/Modules/app-hub-module/source/app_hub_service.cpp b/Modules/app-hub-module/source/app_hub_service.cpp index 0b5a4a498..1927403fc 100644 --- a/Modules/app-hub-module/source/app_hub_service.cpp +++ b/Modules/app-hub-module/source/app_hub_service.cpp @@ -205,7 +205,11 @@ tt::std::vector getInstalledPackages() { return packages; } -/** Requires the global lock. Only one refresh and one install or remove can be queued or running at a time. */ +/** + * Requires the global lock. Only one refresh can be queued or running at a time. + * Installs and removes of different apps can be queued together, but an update of all apps excludes them. + * @return the refresh, or the first install, remove or update of all apps + */ const Operation* findPendingOperationOfKind(bool refresh) { if (state->operationRunning && (state->runningOperation.type == APP_HUB_OPERATION_REFRESH) == refresh) { return &state->runningOperation; @@ -539,7 +543,16 @@ error_t enqueue(AppHubOperationType type, const char* id, uint32_t* operationId) return ERROR_INVALID_STATE; } - const Operation* pending = findPendingOperationOfKind(type == APP_HUB_OPERATION_REFRESH); + const Operation* pending; + if (type == APP_HUB_OPERATION_REFRESH) { + pending = findPendingOperationOfKind(true); + } else { + pending = findPendingOperationOfKind(false); + // Installs and removes only conflict with an operation for the same app, or with an update of all apps + if (type != APP_HUB_OPERATION_UPDATE_ALL && (pending == nullptr || pending->type != APP_HUB_OPERATION_UPDATE_ALL)) { + pending = findPendingOperation(id); + } + } if (pending != nullptr) { const bool same = pending->type == type && (id == nullptr || strcmp(pending->app_id, id) == 0); if (same && operationId != nullptr) { diff --git a/Modules/app-hub-module/tests/source/app_hub_test.cpp b/Modules/app-hub-module/tests/source/app_hub_test.cpp index 258ed4967..3c44044d0 100644 --- a/Modules/app-hub-module/tests/source/app_hub_test.cpp +++ b/Modules/app-hub-module/tests/source/app_hub_test.cpp @@ -218,6 +218,38 @@ TEST_CASE("app_hub operations report their outcome and the worker stops when idl task_event_group_destruct(&group); } +TEST_CASE("app_hub queues operations of different apps and runs them in order") { + ensureFixture(); + TaskEventGroup group {}; + task_event_group_construct(&group); + AppHubEventSubscription sub {}; + REQUIRE_EQ(app_hub_event_subscribe(&sub, &group), ERROR_NONE); + + uint32_t firstId = 0; + uint32_t secondId = 0; + REQUIRE_EQ(app_hub_remove("first", &firstId), ERROR_NONE); + REQUIRE_EQ(app_hub_install("second", &secondId), ERROR_NONE); + CHECK_NE(firstId, secondId); + + AppHubEvent event = waitForEvent(sub); + CHECK_EQ(event.type, APP_HUB_EVENT_REMOVING); + CHECK_EQ(event.operation_id, firstId); + event = waitForEvent(sub); + CHECK_EQ(event.type, APP_HUB_EVENT_REMOVED); + CHECK_EQ(event.operation_id, firstId); + CHECK_EQ(event.result, ERROR_NOT_FOUND); + event = waitForEvent(sub); + CHECK_EQ(event.type, APP_HUB_EVENT_INSTALLING); + CHECK_EQ(event.operation_id, secondId); + event = waitForEvent(sub); + CHECK_EQ(event.type, APP_HUB_EVENT_INSTALLED); + CHECK_EQ(event.operation_id, secondId); + CHECK_EQ(event.result, ERROR_NOT_FOUND); + + CHECK_EQ(app_hub_event_unsubscribe(&sub), ERROR_NONE); + task_event_group_destruct(&group); +} + TEST_CASE("app_hub drops newer events while a subscriber's queue is full") { ensureFixture(); TaskEventGroup group {}; diff --git a/Modules/app-module/include/app/install.h b/Modules/app-module/include/app/install.h index 0a7c129d7..f2901020d 100644 --- a/Modules/app-module/include/app/install.h +++ b/Modules/app-module/include/app/install.h @@ -4,6 +4,7 @@ #include #include +#include struct Module; @@ -43,6 +44,12 @@ error_t app_get_install_path(const char* app_id, char* path, size_t path_size); */ error_t app_install(const char* source_path); +/** @return true while an app_install() call is running on any task */ +bool app_install_is_active(void); + +/** @return the number of app_install() calls started since boot, wrapping around */ +uint32_t app_install_get_started_count(void); + /** * Uninstalls a previously app_install()-ed package: stops every one of its apps if currently * running, deletes its install directory, and unregisters all of them (app_manager_remove()). diff --git a/Modules/app-module/source/install.cpp b/Modules/app-module/source/install.cpp index 90e816234..a1cebb4d1 100644 --- a/Modules/app-module/source/install.cpp +++ b/Modules/app-module/source/install.cpp @@ -18,6 +18,7 @@ #include +#include #include #include #include @@ -129,6 +130,13 @@ bool untar_directory(const minitar_entry* entry, const std::string& destination_ } bool untar(const std::string& tar_path, const std::string& destination_path) { + // minitar aborts on a file that isn't made of 512-byte blocks, e.g. a truncated or non-tar file + struct stat file_stat {}; + if (stat(tar_path.c_str(), &file_stat) != 0 || file_stat.st_size % 512 != 0) { + LOG_E(TAG, "%s isn't a valid tar archive", tar_path.c_str()); + return false; + } + minitar archive {}; if (minitar_open(tar_path.c_str(), &archive) != 0) { LOG_E(TAG, "Failed to open %s", tar_path.c_str()); @@ -378,7 +386,7 @@ error_t app_get_install_path(const char* app_id, char* path, size_t path_size) { return ERROR_NONE; } -error_t app_install(const char* source_path) { +static error_t install_package(const char* source_path) { LOG_I(TAG, "Installing app package from %s", source_path); std::string app_parent_path; @@ -476,6 +484,25 @@ error_t app_install(const char* source_path) { return add_result; } +static std::atomic active_install_count = 0; +static std::atomic started_install_count = 0; + +error_t app_install(const char* source_path) { + started_install_count++; + active_install_count++; + const error_t result = install_package(source_path); + active_install_count--; + return result; +} + +bool app_install_is_active(void) { + return active_install_count > 0; +} + +uint32_t app_install_get_started_count(void) { + return started_install_count; +} + error_t app_uninstall(const char* app_id) { LOG_I(TAG, "Uninstalling app %s", app_id); diff --git a/Modules/app-module/tests/source/install_test.cpp b/Modules/app-module/tests/source/install_test.cpp new file mode 100644 index 000000000..e987245fd --- /dev/null +++ b/Modules/app-module/tests/source/install_test.cpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "doctest.h" + +#include + +TEST_CASE("app_install counts the started installs") { + const uint32_t before = app_install_get_started_count(); + CHECK_FALSE(app_install_is_active()); + CHECK_NE(app_install("/nonexistent/package.app"), ERROR_NONE); + CHECK_FALSE(app_install_is_active()); + CHECK_EQ(app_install_get_started_count(), before + 1); +} diff --git a/Modules/c-symbols-module/source/module.cpp b/Modules/c-symbols-module/source/module.cpp index 6098f8741..2d8013f1a 100644 --- a/Modules/c-symbols-module/source/module.cpp +++ b/Modules/c-symbols-module/source/module.cpp @@ -46,8 +46,11 @@ static const ModuleSymbol SYMBOLS[] = { DEFINE_MODULE_SYMBOL(strftime), DEFINE_MODULE_SYMBOL(time), DEFINE_MODULE_SYMBOL(difftime), - DEFINE_MODULE_SYMBOL(localtime), DEFINE_MODULE_SYMBOL(mktime), + DEFINE_MODULE_SYMBOL(gmtime), + DEFINE_MODULE_SYMBOL(gmtime_r), + DEFINE_MODULE_SYMBOL(localtime), + DEFINE_MODULE_SYMBOL(localtime_r), // math.h DEFINE_MODULE_SYMBOL(acoshf), DEFINE_MODULE_SYMBOL(acosf), diff --git a/Modules/gps-module/include/gps/gps.h b/Modules/gps-module/include/gps/gps.h index 4fbc86c53..b164bc37f 100644 --- a/Modules/gps-module/include/gps/gps.h +++ b/Modules/gps-module/include/gps/gps.h @@ -51,6 +51,8 @@ enum GpsEventType { GPS_EVENT_UNSUBSCRIBED, // Last event, device wants to destroy itself and unsubscribed the subscriber. GPS_EVENT_MESSAGE_RMC, GPS_EVENT_MESSAGE_GGA, + GPS_EVENT_MESSAGE_GSV, + GPS_EVENT_MESSAGE_GSA, }; struct GpsEvent { @@ -58,6 +60,8 @@ struct GpsEvent { union { struct minmea_sentence_rmc rmc; struct minmea_sentence_gga gga; + struct minmea_sentence_gsv gsv; + struct minmea_sentence_gsa gsa; } data; }; diff --git a/Modules/location-module/source/gps_provider.cpp b/Modules/location-module/source/gps_provider.cpp index 3344d13a6..aa16d46cf 100644 --- a/Modules/location-module/source/gps_provider.cpp +++ b/Modules/location-module/source/gps_provider.cpp @@ -200,6 +200,9 @@ void receive(GpsProvider& provider) { case GPS_EVENT_MESSAGE_GGA: onSentence(provider, sub.event); break; + case GPS_EVENT_MESSAGE_GSV: + case GPS_EVENT_MESSAGE_GSA: + break; case GPS_EVENT_UNSUBSCRIBED: provider.device = nullptr; break; diff --git a/Modules/lvgl-module/source/widgets/spinner.cpp b/Modules/lvgl-module/source/widgets/spinner.cpp index f2b430a86..aa71cbf7d 100644 --- a/Modules/lvgl-module/source/widgets/spinner.cpp +++ b/Modules/lvgl-module/source/widgets/spinner.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #define LV_USE_PRIVATE_API 1 // For actual lv_obj_t declaration +#include #include #include @@ -27,6 +28,12 @@ lv_obj_t* lvgl_spinner_create(lv_obj_t* parent) { lv_image_set_src(obj, lvgl_get_spinner_image()); + // The spinner image isn't visible on every monochrome background, so it takes the color of the text next to it + if (lvgl_theme_is_mono()) { + lv_obj_set_style_image_recolor(obj, lv_obj_get_style_text_color(parent, LV_PART_MAIN), LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor_opa(obj, LV_OPA_COVER, LV_STATE_DEFAULT); + } + return obj; } diff --git a/Tactility/Private/Tactility/app/setup/SetupPrivate.h b/Tactility/Private/Tactility/app/setup/SetupPrivate.h new file mode 100644 index 000000000..ae3bcd1cc --- /dev/null +++ b/Tactility/Private/Tactility/app/setup/SetupPrivate.h @@ -0,0 +1,131 @@ +#pragma once + +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace tt::app::setup { + +enum class Phase { + Welcome, + StepIntro, + /** The choices of a step that has an intro page, see StepConfiguration::choiceTitle */ + StepChoice, + /** The Wi-Fi network list, shown after the intro page of the Wi-Fi step */ + WifiSetup, + Done +}; + +struct StepConfiguration { + tt::std::string title {}; + tt::std::string description {}; + ::std::function run = nullptr; + /** Optional: shows the page that the step ends on, when coming back from the next step. Called on the LVGL thread. */ + ::std::function showLast = nullptr; + /** Optional: handles the result of the dialog that run() started */ + ::std::function onResult = nullptr; + /** What the page shows below its title */ + enum class Content { + Description, + /** The description, with the choices below it */ + Choice, + /** The time zone list instead of the description */ + TimeZones, + /** The Wi-Fi network list instead of the description, only used by Phase::WifiSetup */ + Wifi + } content = Content::Description; + /** Optional: the title of a separate page with the choices, shown after the intro page that holds the description */ + tt::std::string choiceTitle {}; + /** The labels of the chips of a Content::Choice page */ + tt::std::vector choices {}; + /** The index in choices that is picked, -1 when none. Guarded by the LVGL lock. */ + int32_t choice = -1; + /** Optional: the choice to pick when the page is shown and none was picked yet. Called with the LVGL lock held. */ + ::std::function defaultChoice = nullptr; +}; + +struct Context { + uint32_t appInstanceId; + + Phase phase = Phase::Welcome; + size_t stepIndex = 0; + tt::std::vector steps; + uint32_t pendingStepDialogId = 0; + + // Valid while the window is shown + // The area above the buttons, which scrolls when the page doesn't fit + lv_obj_t* content = nullptr; + lv_obj_t* contentInner = nullptr; + lv_obj_t* titleLabel = nullptr; + lv_obj_t* descriptionLabel = nullptr; + lv_obj_t* backButton = nullptr; + lv_obj_t* skipButton = nullptr; + lv_obj_t* continueButton = nullptr; + lv_obj_t* stepIndicator = nullptr; + lv_obj_t* bottom = nullptr; + lv_obj_t* timeZoneContainer = nullptr; + lv_obj_t* choiceChips = nullptr; + lv_obj_t* wifiContainer = nullptr; + + // Guarded by the LVGL lock + Device* wifiDevice = nullptr; + WifiEventSubscription wifiEventSub {}; + wifimanage::State wifiState; + /** The network that was picked last */ + tt::std::string wifiTarget; + bool wifiConnectFailed = false; + /** The SSIDs of the list items */ + tt::std::vector wifiSsids; + // Wi-Fi work for the app's task, as it blocks. Guarded by the LVGL lock. + TaskEventGroup* eventGroup = nullptr; + /** Actions that block, which the app's task runs. Guarded by the LVGL lock. */ + uint32_t taskActionBit = 0; + tt::std::vector<::std::function> taskActions; + uint32_t wifiActionBit = 0; + bool radioOnRequested = false; + tt::std::string connectRequest; + + ::std::unique_ptr timeZoneView; + // The time zone picked on the time zone page, guarded by the LVGL lock + ::std::string timeZoneName; + ::std::string timeZoneCode; +}; + +// SetupCompletion.cpp + +void markCompleted(); + +// SetupLayout.cpp + +uint32_t getStepCount(const Context* ctx); +enum LvglFontSize getTitleFontSize(); +/** Shows the current page. Requires the LVGL lock. */ +void renderCurrent(Context* ctx); + +// SetupWifi.cpp + +/** Claims the action bit and subscribes to Wi-Fi events, which are signalled on @a eventGroup */ +void wifiStart(Context* ctx, TaskEventGroup* eventGroup); +void wifiStop(Context* ctx); +/** Handles the pending requests and events. Call it from the app's task. */ +void wifiProcess(Context* ctx); +/** Shows the Wi-Fi page and turns Wi-Fi on */ +void enterWifiSetup(Context* ctx); +/** Fills the Wi-Fi container for the current state. Requires the LVGL lock. */ +void renderWifi(Context* ctx); + +} // namespace diff --git a/Tactility/Private/Tactility/app/wificonnect/WifiConnect.h b/Tactility/Private/Tactility/app/wificonnect/WifiConnect.h index 9891b4bfa..d4a0d8de2 100644 --- a/Tactility/Private/Tactility/app/wificonnect/WifiConnect.h +++ b/Tactility/Private/Tactility/app/wificonnect/WifiConnect.h @@ -6,7 +6,8 @@ namespace tt::app::wificonnect { /** * Start the app with optional pre-filled fields. + * @param[in] hideStatusbar hides the statusbar while the app is shown (the "--hide-statusbar" argument) */ -void start(const std::string& ssid = "", const std::string& password = ""); +void start(const std::string& ssid = "", const std::string& password = "", bool hideStatusbar = false); } // namespace diff --git a/Tactility/Private/Tactility/app/wifimanage/Bindings.h b/Tactility/Private/Tactility/app/wifimanage/Bindings.h index 6e06660a2..94014c21b 100644 --- a/Tactility/Private/Tactility/app/wifimanage/Bindings.h +++ b/Tactility/Private/Tactility/app/wifimanage/Bindings.h @@ -4,15 +4,17 @@ namespace tt::app::wifimanage { -typedef void (*OnWifiToggled)(bool enable); -typedef void (*OnConnectSsid)(const std::string& ssid); -typedef void (*OnDisconnect)(); +typedef void (*OnWifiToggled)(void* userData, bool enable); +typedef void (*OnConnectSsid)(void* userData, const std::string& ssid); +typedef void (*OnDisconnect)(void* userData); typedef void (*OnShowApSettings)(const std::string& ssid); typedef void (*OnConnectToHidden)(); /** @return false when no scan is running or starting */ typedef bool (*OnRefresh)(); struct Bindings{ + /** Passed to onWifiToggled, onConnectSsid and onDisconnect */ + void* userData; OnWifiToggled onWifiToggled; OnConnectSsid onConnectSsid; OnDisconnect onDisconnect; diff --git a/Tactility/Private/Tactility/app/wifimanage/State.h b/Tactility/Private/Tactility/app/wifimanage/State.h index 550ab7e5e..ac467010b 100644 --- a/Tactility/Private/Tactility/app/wifimanage/State.h +++ b/Tactility/Private/Tactility/app/wifimanage/State.h @@ -53,7 +53,8 @@ class State final { void setConnectionTarget(const std::string& ssid); std::string getConnectionTarget() const; - void updateApRecords(Device* device); + /** @param[in] keepIfEmpty keeps the current records when the scan found nothing, e.g. because it was aborted by a connection */ + void updateApRecords(Device* device, bool keepIfEmpty = false); template &> Func> void withApRecords(Func&& onApRecords) const { diff --git a/Tactility/Source/app/alertdialog/AlertDialog.cpp b/Tactility/Source/app/alertdialog/AlertDialog.cpp index 7523fd86e..d1481052a 100644 --- a/Tactility/Source/app/alertdialog/AlertDialog.cpp +++ b/Tactility/Source/app/alertdialog/AlertDialog.cpp @@ -14,6 +14,7 @@ #include #include +#include #include namespace tt::app::alertdialog { @@ -103,9 +104,12 @@ void createWidgets(lv_obj_t* parent, void* userData) { } } + // The arrow keys move between the buttons as one row + lvgl_grid_navigation_add(button_wrapper); + if (first_button != nullptr && !lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) { - lv_group_focus_obj(first_button); - lv_obj_add_state(first_button, LV_STATE_FOCUS_KEY); + lv_group_focus_obj(button_wrapper); + lv_gridnav_set_focused(button_wrapper, first_button, LV_ANIM_OFF); } } diff --git a/Tactility/Source/app/apphub/AppHubApp.cpp b/Tactility/Source/app/apphub/AppHubApp.cpp index 6c26b6d67..d1c8c3456 100644 --- a/Tactility/Source/app/apphub/AppHubApp.cpp +++ b/Tactility/Source/app/apphub/AppHubApp.cpp @@ -22,7 +22,6 @@ #include #include #include -#include #include #include @@ -237,13 +236,21 @@ lv_obj_t* createCard(lv_obj_t* parent) { return card; } -void showRefreshFailedError(Context* ctx, const char* message) { +/** Replaces the content with a centered card that holds the status text */ +void showStatusLabel(Context* ctx, const char* text) { lv_obj_clean(ctx->contentWrapper); - auto* label = lv_label_create(createCard(ctx->contentWrapper)); + auto* card = createCard(ctx->contentWrapper); + lv_obj_set_style_text_align(card, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT); + lv_obj_align(card, LV_ALIGN_CENTER, 0, 0); + auto* label = lv_label_create(card); lv_obj_set_width(label, LV_PCT(100)); lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_WRAP); - lv_label_set_text(label, message); + lv_label_set_text(label, text); +} + +void showRefreshFailedError(Context* ctx, const char* message) { + showStatusLabel(ctx, message); lv_obj_set_hidden(ctx->refreshButton, false); updateToolbarActions(ctx, false); @@ -333,15 +340,11 @@ void showApps(Context* ctx) { } } } else if (hasApps) { - auto* label = lv_label_create(createCard(ctx->contentWrapper)); - lv_obj_set_width(label, LV_PCT(100)); - lv_label_set_long_mode(label, LV_LABEL_LONG_MODE_WRAP); - lv_label_set_text(label, getEmptyFilterText(ctx->filter)); + showStatusLabel(ctx, getEmptyFilterText(ctx->filter)); } else if (ctx->refreshOperationId == 0) { showRefreshFailedError(ctx, "Failed to load content"); } else { - auto* spinner = lvgl_spinner_create(ctx->contentWrapper); - lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 0); + showStatusLabel(ctx, "Loading..."); } ctx->mutex.unlock(); } diff --git a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp index c7129894b..90357535c 100644 --- a/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp +++ b/Tactility/Source/app/apphubdetails/AppHubDetailsApp.cpp @@ -55,7 +55,6 @@ struct Context { // Read from the catalog file on this app's own thread (see loadState()), as the widget code // can't do file operations. Written with the LVGL lock held, read by the widget code. AppHubApp app {}; - bool anotherAppBusy = false; tt::std::string description; // Set from the LVGL task (button press), read from this app's own thread (event loop) - @@ -80,23 +79,6 @@ AppHubApp readApp(Context* ctx) { return app; } -/** @return true when App Hub is installing or removing another app, which blocks this app's install and remove */ -bool isAnotherAppBusy(Context* ctx) { - struct Search { - const char* appId; - bool busy; - } search { ctx->appId.c_str(), false }; - app_hub_search(nullptr, [](const AppHubApp* app, void* context) { - auto* search = static_cast(context); - if (app->operation_pending && strcmp(app->id, search->appId) != 0) { - search->busy = true; - return false; - } - return true; - }, &search); - return search.busy; -} - tt::std::string readDescription(Context* ctx) { tt::std::vector buffer(DESCRIPTION_MAX_SIZE); if (app_hub_get_description(ctx->appId.c_str(), buffer.data(), buffer.size()) != ERROR_NONE || buffer[0] == '\0') { @@ -112,14 +94,12 @@ tt::std::string readDescription(Context* ctx) { /** Reads the state from App Hub. Doesn't touch widgets, so it runs without the LVGL lock. */ void loadState(Context* ctx, bool includeDescription) { const auto app = readApp(ctx); - const bool anotherAppBusy = isAnotherAppBusy(ctx); tt::std::string description; if (includeDescription) { description = readDescription(ctx); } lvgl_lock(); ctx->app = app; - ctx->anotherAppBusy = anotherAppBusy; if (includeDescription) { ctx->description = ::std::move(description); } @@ -164,7 +144,7 @@ void onUpdatePressed(lv_event_t* e) { void queueOperation(Context* ctx, error_t (*operation)(const char* id, uint32_t* operation_id), const char* failure) { const error_t error = operation(ctx->appId.c_str(), nullptr); if (error == ERROR_RESOURCE_BUSY) { - alertdialog::start(ctx->appInstanceId, "Busy", "Another app is being installed or removed. Try again when it's done."); + alertdialog::start(ctx->appInstanceId, "Busy", "Another operation is pending. Try again when it's done."); return; } else if (error != ERROR_NONE) { alertdialog::start(ctx->appInstanceId, "Error", failure); @@ -174,7 +154,6 @@ void queueOperation(Context* ctx, error_t (*operation)(const char* id, uint32_t* } void onOperationEvent(Context* ctx, const AppHubEvent& event) { - // Also another app's install or removal, as it blocks the buttons const bool finished = event.type == APP_HUB_EVENT_INSTALLED || event.type == APP_HUB_EVENT_REMOVED; if (finished && ctx->appId == event.app_id && event.result != ERROR_NONE) { LOG_E(TAG, "Operation failed: %s", error_to_string(event.result)); @@ -195,12 +174,10 @@ void updateViews(Context* ctx) { ctx->spinner = lvgl_toolbar_add_spinner_action(ctx->toolbar); lv_obj_set_hidden(ctx->updateLabel, true); - // This app's install or removal continues after the window closes, so it can already be running when the window opens. - // Another app's install or removal blocks this app's buttons until it's done. + // This app's install or removal continues after the window closes, so it can already be queued or running when the window opens. const auto& app = ctx->app; - const bool busy = app.operation_pending || ((app.installed || app.in_catalog) && ctx->anotherAppBusy); - lv_obj_set_hidden(ctx->spinner, !busy); - if (busy) { + lv_obj_set_hidden(ctx->spinner, !app.operation_pending); + if (app.operation_pending) { return; } diff --git a/Tactility/Source/app/setup/Setup.cpp b/Tactility/Source/app/setup/Setup.cpp index 22925795d..2a44ee97a 100644 --- a/Tactility/Source/app/setup/Setup.cpp +++ b/Tactility/Source/app/setup/Setup.cpp @@ -1,14 +1,7 @@ #include -#include - -#include -#include -#include -#include #include #include -#include #include #include @@ -18,29 +11,27 @@ #include #include - -#include +#include #include #include #include -#include #include +#include #include #include #include #include -#include -#include #include #include #include -#include -#include -#include +#include + +// Included last, as it makes std:: resolve to tt::std +#include #ifdef ESP_PLATFORM #include @@ -58,278 +49,44 @@ constexpr auto* TAG = "setup"; namespace { -/** Increase when the setup changes, so users see it again */ -constexpr int SETUP_VERSION = 2; - -bool getCompletedMarkerPath(std::string& outPath) { - char path[128]; - if (paths_get_data_path(path, sizeof(path)) != ERROR_NONE) { - return false; - } - outPath = std::string(path) + "/.setup_complete"; - return true; +const char* getMeshBackendLabel(const char* id) { + const ::std::string_view name = id; + if (name == "meshcore") return "MeshCore"; + if (name == "meshtastic") return "Meshtastic"; + return id; } -} // namespace - -bool isCompleted() { - std::string path; - if (!getCompletedMarkerPath(path)) { - LOG_E(TAG, "Setup path not found"); - return false; - } - if (!file::isFile(path)) { +/** Mesh needs a LoRa radio, and on ESP32 enough memory, which means PSRAM */ +bool isMeshSupported() { +#ifdef ESP_PLATFORM + if (memory_external_total() == 0) { return false; } - // Markers from before versioning are empty and mean version 1 - int version = 1; - const auto content = file::readString(path); - if (content != nullptr && content[0] != '\0') { - version = atoi(reinterpret_cast(content.get())); - } - return version >= SETUP_VERSION; -} - -namespace { - -void markCompleted() { - std::string path; - if (!getCompletedMarkerPath(path)) { - return; - } - file::writeString(path, std::to_string(SETUP_VERSION)); -} - -enum class Phase { - Welcome, - StepIntro, - Done -}; - -struct StepConfiguration { - std::string title; - std::string description; - std::function run; - /** Optional: handles the result of the dialog that run() started */ - std::function onResult = nullptr; - /** What the page shows below its title */ - enum class Content { - Description, - /** The description, with the choices below it */ - Choice, - /** The time zone list instead of the description */ - TimeZones - } content = Content::Description; - /** The labels of the chips of a Content::Choice page */ - std::vector choices; - /** The index in choices that is picked, -1 when none. Guarded by the LVGL lock. */ - int32_t choice = -1; - /** Optional: the choice to pick when the page is shown and none was picked yet. Called with the LVGL lock held. */ - std::function defaultChoice = nullptr; -}; - -struct Context { - uint32_t appInstanceId; - - Phase phase = Phase::Welcome; - size_t stepIndex = 0; - std::vector steps; - uint32_t pendingStepDialogId = 0; - - // Valid while the window is shown - // The area above the buttons, which scrolls when the page doesn't fit - lv_obj_t* content = nullptr; - lv_obj_t* contentInner = nullptr; - lv_obj_t* titleLabel = nullptr; - lv_obj_t* descriptionLabel = nullptr; - lv_obj_t* backButton = nullptr; - lv_obj_t* skipButton = nullptr; - lv_obj_t* continueButton = nullptr; - lv_obj_t* stepIndicator = nullptr; - lv_obj_t* bottom = nullptr; - lv_obj_t* timeZoneContainer = nullptr; - lv_obj_t* choiceChips = nullptr; - - std::unique_ptr timeZoneView; - // The time zone picked on the time zone page, guarded by the LVGL lock - std::string timeZoneName; - std::string timeZoneCode; -}; - -/** The welcome page, a page per step, and the completion page */ -uint32_t getPageCount(const Context* ctx) { - return static_cast(ctx->steps.size()) + 2; -} - -uint32_t getPage(const Context* ctx) { - switch (ctx->phase) { - case Phase::Welcome: - return 0; - case Phase::StepIntro: - return static_cast(ctx->stepIndex) + 1; - case Phase::Done: - return getPageCount(ctx) - 1; - } - return 0; -} - - -/** The welcome page counts as the first step, followed by the configuration steps */ -uint32_t getStepCount(const Context* ctx) { - return static_cast(ctx->steps.size()) + 1; -} - -/** The steps before the current one, or all of them once setup is done */ -uint32_t getFinishedStepCount(const Context* ctx) { - switch (ctx->phase) { - case Phase::Welcome: - return 0; - case Phase::StepIntro: - return static_cast(ctx->stepIndex) + 1; - case Phase::Done: - return getStepCount(ctx); - } - return 0; -} - -StepConfiguration::Content getContent(const Context* ctx) { - return ctx->phase == Phase::StepIntro ? ctx->steps[ctx->stepIndex].content : StepConfiguration::Content::Description; -} - -/** Shows which choice of the current step is picked */ -void renderChoiceChips(Context* ctx) { - const int32_t choice = ctx->steps[ctx->stepIndex].choice; - const uint32_t count = lv_obj_get_child_count(ctx->choiceChips); - for (uint32_t i = 0; i < count; i++) { - lv_obj_set_state(lv_obj_get_child(ctx->choiceChips, static_cast(i)), LV_STATE_CHECKED, static_cast(i) == choice); - } -} - -void onChoiceChipPressed(lv_event_t* event); - -/** Replaces the chips with the choices of the current step */ -void createChoiceChips(Context* ctx) { - lv_obj_clean(ctx->choiceChips); - const auto& choices = ctx->steps[ctx->stepIndex].choices; - for (size_t i = 0; i < choices.size(); i++) { - auto* chip = lvgl_chip_create(ctx->choiceChips); - lv_label_set_text(lv_label_create(chip), choices[i].c_str()); - lv_obj_set_user_data(chip, reinterpret_cast(static_cast(i))); - lv_obj_add_event_cb(chip, onChoiceChipPressed, LV_EVENT_SHORT_CLICKED, ctx); - } -} - -/** Small widgets get smaller titles */ -enum LvglFontSize getTitleFontSize() { - const auto size = lvgl_theme_get_size(); - if (size <= 20) { - return FONT_SIZE_SMALL; - } - return size < 50 ? FONT_SIZE_DEFAULT : FONT_SIZE_LARGE; -} - -/** - * The time zone page shows the title at the top and the list below it, the other pages center their text. - * Pages that don't fit above the buttons scroll. - */ -void renderLayout(Context* ctx) { - const auto content = getContent(ctx); - const bool showsTimeZones = content == StepConfiguration::Content::TimeZones; - const bool showsChoice = content == StepConfiguration::Content::Choice; - const bool hasTimeZoneWidgets = lv_obj_get_child_count(ctx->timeZoneContainer) > 0; - lv_obj_set_hidden(ctx->descriptionLabel, showsTimeZones); - lv_obj_set_hidden(ctx->timeZoneContainer, !showsTimeZones); - lv_obj_set_hidden(ctx->choiceChips, !showsChoice); - // The group skips disabled widgets, but not hidden ones - lv_obj_set_state(ctx->choiceChips, LV_STATE_DISABLED, !showsChoice); - lv_obj_scroll_to_y(ctx->content, 0, LV_ANIM_OFF); - - lv_area_t parent; - lv_area_t bottom; - if (!showsTimeZones) { - if (hasTimeZoneWidgets) { - lv_obj_clean(ctx->timeZoneContainer); - ctx->timeZoneView->destroyWidgets(); - } - const int titleMargin = lvgl_get_text_font_height(getTitleFontSize()); - if (showsChoice) { - createChoiceChips(ctx); - // Continue confirms the picked choice - renderChoiceChips(ctx); - lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->steps[ctx->stepIndex].choice < 0); - } else { - lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED); - } - lv_obj_set_style_pad_row(ctx->contentInner, showsChoice ? titleMargin / 2 : titleMargin, LV_STATE_DEFAULT); - lv_obj_set_style_pad_top(ctx->contentInner, 0, LV_STATE_DEFAULT); - lv_obj_set_style_min_height(ctx->contentInner, LV_PCT(100), LV_STATE_DEFAULT); - // Fills the space above the buttons - lv_obj_update_layout(lv_obj_get_parent(ctx->content)); - lv_obj_get_content_coords(lv_obj_get_parent(ctx->content), &parent); - lv_obj_get_coords(ctx->bottom, &bottom); - lv_obj_set_height(ctx->content, bottom.y1 - parent.y1); - } else { - if (!hasTimeZoneWidgets) { - ctx->timeZoneView->createWidgets(ctx->timeZoneContainer); +#endif + for (size_t i = 0; i < mesh_get_radio_count(); i++) { + MeshRadioKind kind; + if (mesh_get_radio_kind(i, &kind) == ERROR_NONE && kind == MESH_RADIO_LORA) { + return mesh_get_backend_count() > 0; } - // Continue confirms the picked time zone - lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->timeZoneName.empty()); - - lv_obj_set_style_pad_row(ctx->contentInner, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_top(ctx->contentInner, LV_DPX(8), LV_STATE_DEFAULT); - lv_obj_set_style_min_height(ctx->contentInner, 0, LV_STATE_DEFAULT); - lv_obj_set_height(ctx->content, LV_SIZE_CONTENT); - - // The list fills the space between the title and the buttons - lv_obj_update_layout(lv_obj_get_parent(ctx->timeZoneContainer)); - lv_area_t title; - lv_obj_get_coords(ctx->content, &title); - lv_obj_get_coords(ctx->bottom, &bottom); - lv_obj_get_content_coords(lv_obj_get_parent(ctx->timeZoneContainer), &parent); - lv_obj_set_pos(ctx->timeZoneContainer, 0, title.y2 + LV_DPX(4) - parent.y1); - lv_obj_set_size(ctx->timeZoneContainer, LV_PCT(100), bottom.y1 - title.y2 - LV_DPX(8)); } + return false; +} - // Only a page that doesn't fit takes the focus, so the keys can scroll it - lv_obj_update_layout(ctx->content); - const bool scrolls = lv_obj_get_scroll_bottom(ctx->content) > 0 || lv_obj_get_scroll_top(ctx->content) > 0; - lv_obj_set_state(ctx->content, LV_STATE_DISABLED, !scrolls); +/** Runs @a action on the app's task, for work that blocks or does file operations */ +void runOnAppTask(Context* ctx, ::std::function action) { + lvgl_lock(); + ctx->taskActions.push_back(::std::move(action)); + lvgl_unlock(); + task_event_group_signal(ctx->eventGroup, ctx->taskActionBit); } -void renderCurrent(Context* ctx) { - lvgl_step_indicator_set_finished_count(ctx->stepIndicator, getFinishedStepCount(ctx)); - lv_obj_set_hidden(ctx->backButton, ctx->phase == Phase::Welcome); - switch (ctx->phase) { - case Phase::Welcome: { - lv_label_set_text(ctx->titleLabel, "Welcome"); - auto device_names = string::split(std::string(CONFIG_TT_DEVICE_NAME_SIMPLE), ","); - lv_label_set_text_fmt(ctx->descriptionLabel, "It's time to set up your %s!", device_names.front().c_str()); - lv_obj_set_hidden(ctx->skipButton, true); - lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Continue"); - break; - } - case Phase::StepIntro: { - const auto& step = ctx->steps[ctx->stepIndex]; - lv_label_set_text(ctx->titleLabel, step.title.c_str()); - lv_label_set_text(ctx->descriptionLabel, step.description.c_str()); - lv_obj_set_hidden(ctx->skipButton, false); - lv_label_set_text(lv_obj_get_child(ctx->skipButton, 0), "Skip"); - lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Continue"); - break; - } - case Phase::Done: - lv_label_set_text(ctx->titleLabel, "Setup Complete"); - lv_label_set_text(ctx->descriptionLabel, "You're all set."); - lv_obj_set_hidden(ctx->skipButton, true); - lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Finish"); - break; - } - renderLayout(ctx); - // Without a touch screen, Finish has the focus - if (ctx->phase == Phase::Done && !lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) { - auto* buttonRow = lv_obj_get_parent(ctx->continueButton); - lv_group_focus_obj(buttonRow); - lv_gridnav_set_focused(buttonRow, ctx->continueButton, LV_ANIM_OFF); +void processTaskActions(Context* ctx) { + lvgl_lock(); + auto actions = ::std::move(ctx->taskActions); + ctx->taskActions.clear(); + lvgl_unlock(); + for (auto& action : actions) { + action(); } } @@ -355,25 +112,51 @@ void advanceTo(Context* ctx, size_t index) { lvgl_unlock(); } -void onBackClicked(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - // The previous page: a step's intro page, or the welcome page before the first step - const size_t previousPage = getPage(ctx) - 1; - if (ctx->phase == Phase::Welcome) { - return; - } else if (previousPage == 0) { - ctx->phase = Phase::Welcome; +/** Shows the page that a step ends on, so that going back from the next step shows the state it was left in */ +void showEndOfStep(Context* ctx, size_t index) { + auto& step = ctx->steps[index]; + if (step.showLast) { + ctx->stepIndex = index; + step.showLast(); + } else if (!step.choiceTitle.empty()) { + ctx->stepIndex = index; + ctx->phase = Phase::StepChoice; lvgl_lock(); renderCurrent(ctx); lvgl_unlock(); } else { - advanceTo(ctx, previousPage - 1); + advanceTo(ctx, index); + } +} + +void onBackClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + switch (ctx->phase) { + case Phase::Welcome: + break; + case Phase::StepChoice: + case Phase::WifiSetup: + advanceTo(ctx, ctx->stepIndex); + break; + case Phase::StepIntro: + if (ctx->stepIndex == 0) { + ctx->phase = Phase::Welcome; + lvgl_lock(); + renderCurrent(ctx); + lvgl_unlock(); + } else { + showEndOfStep(ctx, ctx->stepIndex - 1); + } + break; + case Phase::Done: + showEndOfStep(ctx, ctx->steps.size() - 1); + break; } } void onSkipClicked(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); - if (ctx->phase == Phase::StepIntro) { + if (ctx->phase == Phase::StepIntro || ctx->phase == Phase::StepChoice || ctx->phase == Phase::WifiSetup) { advanceTo(ctx, ctx->stepIndex + 1); } } @@ -385,23 +168,33 @@ void onContinueClicked(lv_event_t* event) { advanceTo(ctx, 0); break; case Phase::StepIntro: + if (ctx->steps[ctx->stepIndex].choiceTitle.empty()) { + ctx->steps[ctx->stepIndex].run(); + } else { + ctx->phase = Phase::StepChoice; + lvgl_lock(); + renderCurrent(ctx); + lvgl_unlock(); + } + break; + case Phase::StepChoice: ctx->steps[ctx->stepIndex].run(); break; + case Phase::WifiSetup: + advanceTo(ctx, ctx->stepIndex + 1); + break; case Phase::Done: { - markCompleted(); - app_event_emit_close(ctx->appInstanceId); + // Queued after the changes of the steps, so the marker is only saved once they were + const auto appInstanceId = ctx->appInstanceId; + runOnAppTask(ctx, [appInstanceId] { + markCompleted(); + app_event_emit_close(appInstanceId); + }); break; } } } -void onChoiceChipPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - ctx->steps[ctx->stepIndex].choice = static_cast(reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event)))); - renderChoiceChips(ctx); - lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED); -} - void createWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); lvgl::systemBarsSetStatusbarVisible(false); @@ -450,6 +243,13 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_obj_set_style_pad_hor(ctx->timeZoneContainer, LV_DPX(8), LV_STATE_DEFAULT); lv_obj_set_hidden(ctx->timeZoneContainer, true); + // Filled with the network list on the Wi-Fi page + ctx->wifiContainer = lv_obj_create(parent); + lv_obj_remove_style_all(ctx->wifiContainer); + lv_obj_set_flex_flow(ctx->wifiContainer, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_hor(ctx->wifiContainer, LV_DPX(8), LV_STATE_DEFAULT); + lv_obj_set_hidden(ctx->wifiContainer, true); + // The buttons, with the progress through the pages below them at the bottom auto* bottom = lv_obj_create(parent); ctx->bottom = bottom; @@ -513,6 +313,7 @@ void destroyWidgets(void* userData) { ctx->bottom = nullptr; ctx->timeZoneContainer = nullptr; ctx->choiceChips = nullptr; + ctx->wifiContainer = nullptr; ctx->timeZoneView->destroyWidgets(); } @@ -520,6 +321,8 @@ int32_t appMain(int argc, char* argv[]) { uint32_t appInstanceId = app_scheduler_current_app_id(); Context ctx {}; ctx.appInstanceId = appInstanceId; + // Whether the location step enabled location services, which is not the case when it was skipped + bool locationEnabled = false; ctx.steps = { #if defined(CONFIG_TT_TOUCH_CALIBRATION_REQUIRED) { @@ -531,29 +334,19 @@ int32_t appMain(int argc, char* argv[]) { { .title = "Wi-Fi Setup", .description = "Let's connect to a Wi-Fi access point.", - .run = [&ctx] { - getMainDispatcher().dispatch([] { - Device* wifi_device = nullptr; - if (device_get_first_by_type(&WIFI_TYPE, &wifi_device) == ERROR_NONE) { - if (wifi_set_radio_on(wifi_device) == ERROR_NONE && wifi_settings_set_enable(true) != ERROR_NONE) { - LOG_E(TAG, "Failed to save the Wi-Fi state"); - } - device_put(wifi_device); - } - }); - ctx.pendingStepDialogId = wifimanage::start(ctx.appInstanceId); - } + .run = [&ctx] { enterWifiSetup(&ctx); }, + .showLast = [&ctx] { enterWifiSetup(&ctx); } } }; - const size_t locationStepIndex = ctx.steps.size(); ctx.steps.push_back({ .title = "Location Services", - .description = "Do you want to allow apps to retrieve your location? It uses GPS and/or contacts geoiplookup.io", - .run = [&ctx] { + .description = "Allow apps to retrieve your location. It uses GPS and/or contacts geoiplookup.io", + .run = [&ctx, &locationEnabled] { // The order of the choices: precise, approximate, disabled const int32_t choice = ctx.steps[ctx.stepIndex].choice; - if (choice == 0 || choice == 1) { + locationEnabled = choice == 0 || choice == 1; + if (locationEnabled) { location_set_precision(choice == 0 ? LOCATION_PRECISION_PRECISE : LOCATION_PRECISION_APPROXIMATE); location_set_enabled(true); } else { @@ -562,6 +355,7 @@ int32_t appMain(int argc, char* argv[]) { advanceTo(&ctx, ctx.stepIndex + 1); }, .content = StepConfiguration::Content::Choice, + .choiceTitle = "App location access:", .choices = { "Precise", "Approximate", "Disabled" } }); @@ -575,7 +369,7 @@ int32_t appMain(int argc, char* argv[]) { }, .content = StepConfiguration::Content::TimeZones }); - ctx.timeZoneView = std::make_unique([&ctx](const std::string& name, const std::string& code) { + ctx.timeZoneView = ::std::make_unique([&ctx](const ::std::string& name, const ::std::string& code) { ctx.timeZoneName = name; ctx.timeZoneCode = code; if (ctx.continueButton != nullptr) { @@ -595,10 +389,52 @@ int32_t appMain(int argc, char* argv[]) { .content = StepConfiguration::Content::Choice, .choices = { "Enabled", "Disabled" }, // Enabled unless location services are, as the time needs them - .defaultChoice = [&ctx, locationStepIndex] { - const int32_t locationChoice = ctx.steps[locationStepIndex].choice; - return (locationChoice == 0 || locationChoice == 1) ? 0 : 1; + .defaultChoice = [&locationEnabled] { + return locationEnabled ? 0 : 1; + } + }); + } + + if (isMeshSupported()) { + // The first choice is "Disabled", followed by the backends + auto backendIds = ::std::make_shared>(); + tt::std::vector labels = { "Disabled" }; + for (size_t i = 0; i < mesh_get_backend_count(); i++) { + char id[MESH_BACKEND_ID_MAX_LENGTH + 1]; + if (mesh_get_backend_id(i, id, sizeof(id)) == ERROR_NONE) { + backendIds->emplace_back(id); + labels.push_back(getMeshBackendLabel(id)); + } + } + // Keeps the current setting, so accepting the default doesn't disable Mesh for a user who enabled it + int32_t currentChoice = 0; + char currentBackend[MESH_BACKEND_ID_MAX_LENGTH + 1]; + if (mesh_is_enabled() && mesh_get_backend(currentBackend, sizeof(currentBackend)) == ERROR_NONE) { + for (size_t i = 0; i < backendIds->size(); i++) { + if ((*backendIds)[i] == currentBackend) { + currentChoice = static_cast(i) + 1; + break; + } } + } + ctx.steps.push_back({ + .title = "Mesh", + .description = "Enable Mesh?", + .run = [&ctx, backendIds] { + const int32_t choice = ctx.steps[ctx.stepIndex].choice; + runOnAppTask(&ctx, [backendIds, choice] { + if (choice > 0 && choice <= static_cast(backendIds->size())) { + mesh_set_backend((*backendIds)[choice - 1].c_str()); + mesh_set_enabled(true); + } else { + mesh_set_enabled(false); + } + }); + advanceTo(&ctx, ctx.stepIndex + 1); + }, + .content = StepConfiguration::Content::Choice, + .choices = ::std::move(labels), + .choice = currentChoice }); } @@ -608,6 +444,12 @@ int32_t appMain(int argc, char* argv[]) { AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); + ctx.eventGroup = &event_group; + if (task_event_group_claim_bit(&event_group, &ctx.taskActionBit) != ERROR_NONE) { + LOG_W(TAG, "Failed to claim a task action bit"); + } + wifiStart(&ctx, &event_group); + WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); bool shouldClose = false; @@ -636,8 +478,13 @@ int32_t appMain(int argc, char* argv[]) { } if (shouldClose) break; } + + wifiProcess(&ctx); + processTaskActions(&ctx); } + wifiStop(&ctx); + window_manager_remove(window); check(app_event_unsubscribe(&sub) == ERROR_NONE); task_event_group_destruct(&event_group); @@ -663,3 +510,4 @@ extern const ::AppManifest manifest = { }; } + diff --git a/Tactility/Source/app/setup/SetupCompletion.cpp b/Tactility/Source/app/setup/SetupCompletion.cpp new file mode 100644 index 000000000..03362922b --- /dev/null +++ b/Tactility/Source/app/setup/SetupCompletion.cpp @@ -0,0 +1,55 @@ +#include + +#include + +#include +#include + +// Included last, as it makes std:: resolve to tt::std +#include + +namespace tt::app::setup { + +constexpr auto* TAG = "setup"; + +/** Increase when the setup changes, so users see it again */ +constexpr int SETUP_VERSION = 3; + +static bool getCompletedMarkerPath(::std::string& outPath) { + char path[128]; + if (paths_get_data_path(path, sizeof(path)) != ERROR_NONE) { + return false; + } + outPath = ::std::string(path) + "/.setup_complete"; + return true; +} + +bool isCompleted() { + ::std::string path; + if (!getCompletedMarkerPath(path)) { + LOG_E(TAG, "Setup path not found"); + return false; + } + if (!file::isFile(path)) { + return false; + } + // Markers from before versioning are empty and mean version 1 + int version = 1; + const auto content = file::readString(path); + if (content != nullptr && content[0] != '\0') { + version = atoi(reinterpret_cast(content.get())); + } + return version >= SETUP_VERSION; +} + +void markCompleted() { + ::std::string path; + if (!getCompletedMarkerPath(path)) { + return; + } + if (!file::writeString(path, ::std::to_string(SETUP_VERSION))) { + LOG_E(TAG, "Failed to save setup completion marker"); + } +} + +} diff --git a/Tactility/Source/app/setup/SetupLayout.cpp b/Tactility/Source/app/setup/SetupLayout.cpp new file mode 100644 index 000000000..8341c9cc1 --- /dev/null +++ b/Tactility/Source/app/setup/SetupLayout.cpp @@ -0,0 +1,232 @@ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// Included last, as it makes std:: resolve to tt::std +#include + +#ifdef ESP_PLATFORM +#include +#endif + +namespace tt::app::setup { + + +/** The welcome page counts as the first step, followed by the configuration steps */ +uint32_t getStepCount(const Context* ctx) { + return static_cast(ctx->steps.size()) + 1; +} + +/** The steps before the current one, or all of them once setup is done */ +static uint32_t getFinishedStepCount(const Context* ctx) { + switch (ctx->phase) { + case Phase::Welcome: + return 0; + case Phase::StepIntro: + case Phase::StepChoice: + case Phase::WifiSetup: + return static_cast(ctx->stepIndex) + 1; + case Phase::Done: + return getStepCount(ctx); + } + return 0; +} + +static StepConfiguration::Content getContent(const Context* ctx) { + switch (ctx->phase) { + case Phase::StepIntro: { + const auto& step = ctx->steps[ctx->stepIndex]; + // The choices are on their own page + if (!step.choiceTitle.empty()) { + return StepConfiguration::Content::Description; + } + return step.content; + } + case Phase::StepChoice: + return StepConfiguration::Content::Choice; + case Phase::WifiSetup: + return StepConfiguration::Content::Wifi; + default: + return StepConfiguration::Content::Description; + } +} + +/** Shows which choice of the current step is picked */ +static void renderChoiceChips(Context* ctx) { + const int32_t choice = ctx->steps[ctx->stepIndex].choice; + const uint32_t count = lv_obj_get_child_count(ctx->choiceChips); + for (uint32_t i = 0; i < count; i++) { + lv_obj_set_state(lv_obj_get_child(ctx->choiceChips, static_cast(i)), LV_STATE_CHECKED, static_cast(i) == choice); + } +} + +static void onChoiceChipPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->steps[ctx->stepIndex].choice = static_cast(reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event)))); + renderChoiceChips(ctx); + lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED); +} + +/** Replaces the chips with the choices of the current step */ +static void createChoiceChips(Context* ctx) { + lv_obj_clean(ctx->choiceChips); + const auto& choices = ctx->steps[ctx->stepIndex].choices; + for (size_t i = 0; i < choices.size(); i++) { + auto* chip = lvgl_chip_create(ctx->choiceChips); + lv_label_set_text(lv_label_create(chip), choices[i].c_str()); + lv_obj_set_user_data(chip, reinterpret_cast(static_cast(i))); + lv_obj_add_event_cb(chip, onChoiceChipPressed, LV_EVENT_SHORT_CLICKED, ctx); + } +} + +/** Small widgets get smaller titles */ +enum LvglFontSize getTitleFontSize() { + const auto size = lvgl_theme_get_size(); + if (size <= 20) { + return FONT_SIZE_SMALL; + } + return size < 50 ? FONT_SIZE_DEFAULT : FONT_SIZE_LARGE; +} + +/** + * The time zone page shows the title at the top and the list below it, the other pages center their text. + * Pages that don't fit above the buttons scroll. + */ +static void renderLayout(Context* ctx) { + const auto content = getContent(ctx); + const bool showsTimeZones = content == StepConfiguration::Content::TimeZones; + const bool showsWifi = content == StepConfiguration::Content::Wifi; + const bool showsChoice = content == StepConfiguration::Content::Choice; + const bool hasTimeZoneWidgets = lv_obj_get_child_count(ctx->timeZoneContainer) > 0; + lv_obj_set_hidden(ctx->descriptionLabel, showsTimeZones || showsWifi || ctx->phase == Phase::StepChoice); + lv_obj_set_hidden(ctx->timeZoneContainer, !showsTimeZones); + lv_obj_set_hidden(ctx->wifiContainer, !showsWifi); + lv_obj_set_hidden(ctx->choiceChips, !showsChoice); + // The group skips disabled widgets, but not hidden ones + lv_obj_set_state(ctx->choiceChips, LV_STATE_DISABLED, !showsChoice); + lv_obj_scroll_to_y(ctx->content, 0, LV_ANIM_OFF); + + lv_area_t parent; + lv_area_t bottom; + if (!showsWifi) { + lv_obj_clean(ctx->wifiContainer); + } + if (!showsTimeZones && !showsWifi) { + if (hasTimeZoneWidgets) { + lv_obj_clean(ctx->timeZoneContainer); + ctx->timeZoneView->destroyWidgets(); + } + const int titleMargin = lvgl_get_text_font_height(getTitleFontSize()); + if (showsChoice) { + createChoiceChips(ctx); + // Continue confirms the picked choice + renderChoiceChips(ctx); + lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->steps[ctx->stepIndex].choice < 0); + } else { + lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED); + } + lv_obj_set_style_pad_row(ctx->contentInner, showsChoice ? titleMargin / 2 : titleMargin, LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(ctx->contentInner, 0, LV_STATE_DEFAULT); + lv_obj_set_style_min_height(ctx->contentInner, LV_PCT(100), LV_STATE_DEFAULT); + // Fills the space above the buttons + lv_obj_update_layout(lv_obj_get_parent(ctx->content)); + lv_obj_get_content_coords(lv_obj_get_parent(ctx->content), &parent); + lv_obj_get_coords(ctx->bottom, &bottom); + lv_obj_set_height(ctx->content, bottom.y1 - parent.y1); + } else { + // The list fills the space between the title and the buttons + auto* listContainer = showsWifi ? ctx->wifiContainer : ctx->timeZoneContainer; + if (showsWifi) { + if (hasTimeZoneWidgets) { + lv_obj_clean(ctx->timeZoneContainer); + ctx->timeZoneView->destroyWidgets(); + } + renderWifi(ctx); + } else { + if (!hasTimeZoneWidgets) { + ctx->timeZoneView->createWidgets(ctx->timeZoneContainer); + } + // Continue confirms the picked time zone + lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->timeZoneName.empty()); + } + + lv_obj_set_style_pad_row(ctx->contentInner, 0, LV_STATE_DEFAULT); + lv_obj_set_style_pad_top(ctx->contentInner, LV_DPX(8), LV_STATE_DEFAULT); + lv_obj_set_style_min_height(ctx->contentInner, 0, LV_STATE_DEFAULT); + lv_obj_set_height(ctx->content, LV_SIZE_CONTENT); + + lv_obj_update_layout(lv_obj_get_parent(listContainer)); + lv_area_t title; + lv_obj_get_coords(ctx->content, &title); + lv_obj_get_coords(ctx->bottom, &bottom); + lv_obj_get_content_coords(lv_obj_get_parent(listContainer), &parent); + lv_obj_set_pos(listContainer, 0, title.y2 + LV_DPX(4) - parent.y1); + lv_obj_set_size(listContainer, LV_PCT(100), bottom.y1 - title.y2 - LV_DPX(8)); + } + + // Only a page that doesn't fit takes the focus, so the keys can scroll it + lv_obj_update_layout(ctx->content); + const bool scrolls = lv_obj_get_scroll_bottom(ctx->content) > 0 || lv_obj_get_scroll_top(ctx->content) > 0; + lv_obj_set_state(ctx->content, LV_STATE_DISABLED, !scrolls); +} + +void renderCurrent(Context* ctx) { + lvgl_step_indicator_set_finished_count(ctx->stepIndicator, getFinishedStepCount(ctx)); + lv_obj_set_hidden(ctx->backButton, ctx->phase == Phase::Welcome); + switch (ctx->phase) { + case Phase::Welcome: { + lv_label_set_text(ctx->titleLabel, "Welcome"); + auto device_names = string::split(::std::string(CONFIG_TT_DEVICE_NAME_SIMPLE), ","); + lv_label_set_text_fmt(ctx->descriptionLabel, "It's time to set up your %s!", device_names.front().c_str()); + lv_obj_set_hidden(ctx->skipButton, true); + lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Continue"); + break; + } + case Phase::StepIntro: { + const auto& step = ctx->steps[ctx->stepIndex]; + lv_label_set_text(ctx->titleLabel, step.title.c_str()); + lv_label_set_text(ctx->descriptionLabel, step.description.c_str()); + lv_obj_set_hidden(ctx->skipButton, false); + lv_label_set_text(lv_obj_get_child(ctx->skipButton, 0), "Skip"); + lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Continue"); + break; + } + case Phase::StepChoice: { + lv_label_set_text(ctx->titleLabel, ctx->steps[ctx->stepIndex].choiceTitle.c_str()); + lv_obj_set_hidden(ctx->skipButton, false); + lv_label_set_text(lv_obj_get_child(ctx->skipButton, 0), "Skip"); + lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Continue"); + break; + } + case Phase::WifiSetup: { + lv_label_set_text(ctx->titleLabel, ctx->steps[ctx->stepIndex].title.c_str()); + lv_obj_set_hidden(ctx->skipButton, false); + lv_label_set_text(lv_obj_get_child(ctx->skipButton, 0), "Skip"); + lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Continue"); + break; + } + case Phase::Done: + lv_label_set_text(ctx->titleLabel, "Setup Complete"); + lv_label_set_text(ctx->descriptionLabel, "You're all set."); + lv_obj_set_hidden(ctx->skipButton, true); + lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Finish"); + break; + } + renderLayout(ctx); + // Without a touch screen, Finish has the focus + if (ctx->phase == Phase::Done && !lvgl_indev_exists(LV_INDEV_TYPE_POINTER)) { + auto* buttonRow = lv_obj_get_parent(ctx->continueButton); + lv_group_focus_obj(buttonRow); + lv_gridnav_set_focused(buttonRow, ctx->continueButton, LV_ANIM_OFF); + } +} + +} diff --git a/Tactility/Source/app/setup/SetupWifi.cpp b/Tactility/Source/app/setup/SetupWifi.cpp new file mode 100644 index 000000000..33ab232a0 --- /dev/null +++ b/Tactility/Source/app/setup/SetupWifi.cpp @@ -0,0 +1,280 @@ +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include + +// Included last, as it makes std:: resolve to tt::std +#include + +namespace tt::app::setup { + +constexpr auto* TAG = "setup"; + +static void connectToNetwork(Context* ctx, const tt::std::string& ssid) { + ctx->wifiTarget = ssid; + ctx->wifiConnectFailed = false; + ctx->connectRequest = ssid; + task_event_group_signal(ctx->eventGroup, ctx->wifiActionBit); +} + +static void onWifiNetworkClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + const auto index = reinterpret_cast(lv_obj_get_user_data(lv_event_get_current_target_obj(event))); + if (index >= ctx->wifiSsids.size()) { + return; + } + const tt::std::string ssid = ctx->wifiSsids[index]; + if (ctx->wifiState.getStationState() == WIFI_STATION_STATE_CONNECTED && ctx->wifiState.getConnectionTarget() == ssid.c_str()) { + return; + } + connectToNetwork(ctx, ssid); +} + +static void onWifiRetryClicked(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + connectToNetwork(ctx, ctx->wifiTarget); +} + +/** A text with a spinner next to it */ +static void createBusyRow(lv_obj_t* parent, const char* text) { + auto* row = lv_obj_create(parent); + lv_obj_remove_style_all(row); + lv_obj_set_size(row, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(row, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(row, LV_DPX(8), LV_STATE_DEFAULT); + lv_label_set_text(lv_label_create(row), text); + lvgl_spinner_create(row); +} + +/** Shows the network list, or the connection progress or failure in its place */ +void renderWifi(Context* ctx) { + auto* container = ctx->wifiContainer; + lv_obj_clean(container); + ctx->wifiSsids.clear(); + + const auto station = ctx->wifiState.getStationState(); + lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, station != WIFI_STATION_STATE_CONNECTED); + + if (station == WIFI_STATION_STATE_CONNECTION_PENDING) { + lv_obj_set_flex_align(container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + createBusyRow(container, "Connecting"); + return; + } + + if (ctx->wifiConnectFailed) { + lv_obj_set_flex_align(container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_row(container, LV_DPX(8), LV_STATE_DEFAULT); + lv_label_set_text(lv_label_create(container), "Failed to connect"); + auto* retry = lv_button_create(container); + lv_label_set_text(lv_label_create(retry), "Retry"); + lv_obj_add_event_cb(retry, onWifiRetryClicked, LV_EVENT_SHORT_CLICKED, ctx); + return; + } + + const auto records = ctx->wifiState.getApRecords(); + if (records.empty()) { + lv_obj_set_flex_align(container, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + if (ctx->wifiDevice == nullptr) { + lv_label_set_text(lv_label_create(container), "No Wi-Fi available."); + } else if (ctx->wifiState.isScanning() || !ctx->wifiState.hasScannedAfterRadioOn()) { + createBusyRow(container, "Scanning"); + } else { + lv_label_set_text(lv_label_create(container), "No networks found."); + } + return; + } + + lv_obj_set_flex_align(container, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + auto* card = lvgl_card_create(container); + 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); + 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); + + ::std::set<::std::string> used_ssids; + for (const auto& record : records) { + tt::std::string ssid = record.ssid; + if (ssid.empty() || !used_ssids.insert(ssid.c_str()).second) { + continue; + } + const bool isConnected = station == WIFI_STATION_STATE_CONNECTED && ssid == ctx->wifiState.getConnectionTarget().c_str(); + const tt::std::string text = isConnected ? ssid + " (connected)" : ssid; + auto* button = lvgl_list_add_button(list, isConnected ? LV_SYMBOL_OK : LV_SYMBOL_WIFI, text.c_str()); + lv_obj_set_user_data(button, reinterpret_cast(ctx->wifiSsids.size())); + lv_obj_add_event_cb(button, onWifiNetworkClicked, LV_EVENT_SHORT_CLICKED, ctx); + ctx->wifiSsids.push_back(::std::move(ssid)); + } + lvgl_list_hide_last_divider(card); +} + +static void updateWifiState(Context* ctx) { + if (ctx->wifiDevice == nullptr) { + return; + } + WifiRadioState radio = WIFI_RADIO_STATE_OFF; + WifiStationState station = WIFI_STATION_STATE_DISCONNECTED; + char ssid[33] = {}; + wifi_get_radio_state(ctx->wifiDevice, &radio); + wifi_get_station_state(ctx->wifiDevice, &station); + if (station != WIFI_STATION_STATE_DISCONNECTED) { + wifi_station_get_target_ssid(ctx->wifiDevice, ssid); + } + ctx->wifiState.setRadioState(radio); + ctx->wifiState.setStationState(station); + ctx->wifiState.setConnectionTarget(ssid); + if (station == WIFI_STATION_STATE_CONNECTION_PENDING || station == WIFI_STATION_STATE_CONNECTED) { + ctx->wifiConnectFailed = false; + if (ssid[0] != '\0') { + ctx->wifiTarget = ssid; + } + } +} + +void enterWifiSetup(Context* ctx) { + lvgl_lock(); + ctx->radioOnRequested = true; + lvgl_unlock(); + task_event_group_signal(ctx->eventGroup, ctx->wifiActionBit); + + lvgl_lock(); + ctx->phase = Phase::WifiSetup; + ctx->wifiConnectFailed = false; + updateWifiState(ctx); + if (ctx->wifiDevice != nullptr) { + ctx->wifiState.setScanning(wifi_is_scanning(ctx->wifiDevice)); + ctx->wifiState.updateApRecords(ctx->wifiDevice); + } + if (ctx->titleLabel != nullptr) { + renderCurrent(ctx); + } + lvgl_unlock(); +} + +/** Turns the radio on and connects as requested by the UI, on the app's task as both block */ +static void processWifiActions(Context* ctx) { + lvgl_lock(); + const bool radioOn = ctx->radioOnRequested; + ctx->radioOnRequested = false; + // Drops a connect request when the user has left the Wi-Fi page since it was made + ::std::string ssid; + if (ctx->phase == Phase::WifiSetup) { + ssid = ::std::move(ctx->connectRequest); + } + ctx->connectRequest.clear(); + lvgl_unlock(); + + if (ctx->wifiDevice == nullptr) { + return; + } + if (radioOn) { + if (wifi_set_radio_on(ctx->wifiDevice) == ERROR_NONE) { + if (wifi_settings_set_enable(true) != ERROR_NONE) { + LOG_E(TAG, "Failed to save the Wi-Fi state"); + } + if (!wifi_is_scanning(ctx->wifiDevice) && wifi_scan(ctx->wifiDevice) != ERROR_NONE) { + LOG_E(TAG, "Failed to start scan"); + } + } else { + LOG_E(TAG, "Failed to turn the Wi-Fi radio on"); + } + } + if (!ssid.empty()) { + WifiApSettings ap; + if (wifi_settings_load(ssid.c_str(), &ap) != ERROR_NONE) { + wificonnect::start(ssid.c_str(), "", true); + } else { + const error_t result = wifi_station_connect(ctx->wifiDevice, ap.ssid, ap.password, ap.channel); + if (result != ERROR_NONE) { + LOG_E(TAG, "Failed to connect (%s)", error_to_string(result)); + // No connection result event follows a failure to start + lvgl_lock(); + ctx->wifiConnectFailed = true; + if (ctx->phase == Phase::WifiSetup && ctx->wifiContainer != nullptr) { + renderWifi(ctx); + } + lvgl_unlock(); + } + } + } +} + +static void onWifiEvent(Context* ctx, WifiEvent event) { + lvgl_lock(); + updateWifiState(ctx); + bool rebuild = true; + switch (event.type) { + case WIFI_EVENT_TYPE_SCAN_STARTED: + ctx->wifiState.setScanning(true); + // Rebuilding the list resets the keyboard focus + rebuild = ctx->wifiState.getApRecords().empty(); + break; + case WIFI_EVENT_TYPE_SCAN_FINISHED: + ctx->wifiState.setScanning(false); + ctx->wifiState.updateApRecords(ctx->wifiDevice, true); + break; + case WIFI_EVENT_TYPE_STATION_CONNECTION_RESULT: + ctx->wifiConnectFailed = event.connection_error != WIFI_STATION_CONNECTION_ERROR_NONE; + break; + default: + break; + } + // Covered by another app: createWidgets() renders the current page when the window is shown again + if (rebuild && ctx->phase == Phase::WifiSetup && ctx->wifiContainer != nullptr) { + renderWifi(ctx); + } + lvgl_unlock(); +} + +void wifiStart(Context* ctx, TaskEventGroup* eventGroup) { + ctx->eventGroup = eventGroup; + if (task_event_group_claim_bit(eventGroup, &ctx->wifiActionBit) != ERROR_NONE) { + LOG_W(TAG, "Failed to claim a Wi-Fi action bit"); + } + + Device* wifi_device = nullptr; + if (device_get_first_by_type(&WIFI_TYPE, &wifi_device) == ERROR_NONE) { + if (wifi_event_subscribe(wifi_device, &ctx->wifiEventSub, eventGroup) == ERROR_NONE) { + ctx->wifiDevice = wifi_device; + } else { + LOG_W(TAG, "Failed to subscribe to Wi-Fi events"); + device_put(wifi_device); + } + } +} + +void wifiStop(Context* ctx) { + if (ctx->wifiDevice != nullptr) { + wifi_event_unsubscribe(ctx->wifiDevice, &ctx->wifiEventSub); + device_put(ctx->wifiDevice); + ctx->wifiDevice = nullptr; + } +} + +void wifiProcess(Context* ctx) { + processWifiActions(ctx); + + if (ctx->wifiDevice != nullptr) { + WifiEvent wifi_event {}; + while (wifi_event_poll(&ctx->wifiEventSub, &wifi_event) == ERROR_NONE) { + onWifiEvent(ctx, wifi_event); + } + } +} + +} diff --git a/Tactility/Source/app/timezone/TimeZoneView.cpp b/Tactility/Source/app/timezone/TimeZoneView.cpp index d57835bc1..5dfcc7ebf 100644 --- a/Tactility/Source/app/timezone/TimeZoneView.cpp +++ b/Tactility/Source/app/timezone/TimeZoneView.cpp @@ -233,7 +233,8 @@ void TimeZoneView::showState() { return; } const bool isDetecting = detecting; - lv_obj_set_hidden(detectButton, isDetecting); + // Detection needs location services, which are loaded at boot + lv_obj_set_hidden(detectButton, isDetecting || !location_is_enabled()); lv_obj_set_hidden(detectSpinner, !isDetecting); mutex.lock(); lv_label_set_text(messageLabel, message.c_str()); diff --git a/Tactility/Source/app/wificonnect/WifiConnect.cpp b/Tactility/Source/app/wificonnect/WifiConnect.cpp index 83c6161ee..198be4210 100644 --- a/Tactility/Source/app/wificonnect/WifiConnect.cpp +++ b/Tactility/Source/app/wificonnect/WifiConnect.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include @@ -12,6 +13,7 @@ #include +#include #include #include #include @@ -29,6 +31,7 @@ namespace tt::app::wificonnect { constexpr auto* TAG = "WifiConnect"; +constexpr auto* HIDE_STATUSBAR_ARGUMENT = "--hide-statusbar"; extern const ::AppManifest manifest; @@ -39,6 +42,7 @@ struct Context { std::string initialSsid; std::string initialPassword; + bool hideStatusbar = false; bool connecting = false; bool connectionError = false; @@ -221,12 +225,18 @@ void createBottomButtons(Context* ctx, lv_obj_t* parent) { auto* connect_label = lv_label_create(ctx->connect_button); lv_label_set_text(connect_label, "Connect"); lv_obj_add_event_cb(ctx->connect_button, onConnectPressed, LV_EVENT_SHORT_CLICKED, ctx); + + // The arrow keys move between the checkbox and the button as one row + lvgl_grid_navigation_add(button_container); } // Runs with the LVGL lock already held, possibly on another app's thread - see // WindowDestroyWidgetsFn's warnings. Must stay lock-free: only nulls pointers. void destroyWidgets(void* userData) { auto* ctx = static_cast(userData); + if (ctx->hideStatusbar) { + lvgl::systemBarsSetStatusbarVisible(true); + } ctx->ssid_textarea = nullptr; ctx->ssid_error = nullptr; ctx->password_textarea = nullptr; @@ -271,6 +281,9 @@ lv_obj_t* createErrorLabel(lv_obj_t* parent) { // TODO: Standardize dialogs void createWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); + if (ctx->hideStatusbar) { + lvgl::systemBarsSetStatusbarVisible(false); + } lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); @@ -330,8 +343,17 @@ int32_t appMain(int argc, char* argv[]) { Context ctx {}; ctx.appInstanceId = appInstanceId; - ctx.initialSsid = (argc > 0) ? argv[0] : std::string(); - ctx.initialPassword = (argc > 1) ? argv[1] : std::string(); + // Positional arguments are the SSID and the password + int positional = 0; + for (int i = 0; i < argc; i++) { + if (strcmp(argv[i], HIDE_STATUSBAR_ARGUMENT) == 0) { + ctx.hideStatusbar = true; + } else if (positional++ == 0) { + ctx.initialSsid = argv[i]; + } else { + ctx.initialPassword = argv[i]; + } + } TaskEventGroup event_group {}; task_event_group_construct(&event_group); @@ -402,11 +424,11 @@ int32_t appMain(int argc, char* argv[]) { } // namespace -void start(const std::string& ssid, const std::string& password) { - const char* argv[] = { ssid.c_str(), password.c_str() }; +void start(const std::string& ssid, const std::string& password, bool hideStatusbar) { + const char* argv[] = { ssid.c_str(), password.c_str(), HIDE_STATUSBAR_ARGUMENT }; uint32_t instanceId = 0; AppStartContext context = app_start_context_for_manifest(&manifest); - app_start_context_set_arguments_ext(&context, 2, argv); + app_start_context_set_arguments_ext(&context, hideStatusbar ? 3 : 2, argv); app_start_with_context(&context, &instanceId); } diff --git a/Tactility/Source/app/wifimanage/State.cpp b/Tactility/Source/app/wifimanage/State.cpp index 1760b6ba0..80831267b 100644 --- a/Tactility/Source/app/wifimanage/State.cpp +++ b/Tactility/Source/app/wifimanage/State.cpp @@ -106,13 +106,16 @@ bool State::isScanning() const { return result; } -void State::updateApRecords(Device* device) { +void State::updateApRecords(Device* device, bool keepIfEmpty) { std::vector records(SCAN_RECORD_LIMIT); size_t count = records.size(); if (device == nullptr || wifi_get_scan_results(device, records.data(), &count) != ERROR_NONE) { count = 0; } records.resize(count); + if (keepIfEmpty && count == 0) { + return; + } mutex.lock(); apRecords = std::move(records); diff --git a/Tactility/Source/app/wifimanage/View.cpp b/Tactility/Source/app/wifimanage/View.cpp index 64c25850c..ecea0d6ed 100644 --- a/Tactility/Source/app/wifimanage/View.cpp +++ b/Tactility/Source/app/wifimanage/View.cpp @@ -43,7 +43,7 @@ static void onEnableSwitchChanged(lv_event_t* event) { auto* enable_switch = static_cast(lv_event_get_target(event)); bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED); auto* bindings = static_cast(lv_event_get_user_data(event)); - bindings->onWifiToggled(is_on); + bindings->onWifiToggled(bindings->userData, is_on); } static void onConnectToHiddenClicked(lv_event_t* event) { @@ -66,9 +66,9 @@ void View::connect(lv_event_t* event) { LOG_I(TAG, "Clicked AP: %s", ssid.c_str()); std::string connection_target = self->state->getConnectionTarget(); if (connection_target == ssid) { - self->bindings->onDisconnect(); + self->bindings->onDisconnect(self->bindings->userData); } else { - self->bindings->onConnectSsid(ssid); + self->bindings->onConnectSsid(self->bindings->userData, ssid); } } else { LOG_W(TAG, "Clicked AP: record %zu/%zu does not exist", index, ap_records.size() - 1); diff --git a/Tactility/Source/app/wifimanage/WifiManage.cpp b/Tactility/Source/app/wifimanage/WifiManage.cpp index 15c38269b..106a6c549 100644 --- a/Tactility/Source/app/wifimanage/WifiManage.cpp +++ b/Tactility/Source/app/wifimanage/WifiManage.cpp @@ -3,7 +3,6 @@ #include #include -#include #include #include @@ -32,6 +31,8 @@ extern const ::AppManifest manifest; namespace { +enum class RadioRequest { None, On, Off }; + struct Context { uint32_t appInstanceId; // Set once in appMain() before subscribing, left null if this device has no WiFi driver @@ -46,86 +47,45 @@ struct Context { uint32_t refreshBit = 0; std::atomic needsRefresh {false}; + // Requests from the UI that block, so the app's task handles them. Guarded by the mutex. + uint32_t actionBit = 0; + RadioRequest radioRequest = RadioRequest::None; + bool disconnectRequested = false; + std::string connectRequest; + bool connectRequested = false; + void lock() { mutex.lock(); } void unlock() { mutex.unlock(); } }; -/** Turns the radio on when needed and connects. Runs on the main dispatcher, as turning the radio on blocks. */ -static void connectToAp(const WifiApSettings& ap) { - getMainDispatcher().dispatch([ap] { - Device* wifi_device = nullptr; - if (device_get_first_by_type(&WIFI_TYPE, &wifi_device) != ERROR_NONE) { - LOG_W(TAG, "No WiFi device found"); - return; - } - error_t result = wifi_set_radio_on(wifi_device); - if (result == ERROR_NONE) { - if (wifi_settings_set_enable(true) != ERROR_NONE) { - LOG_E(TAG, "Failed to save the Wi-Fi state"); - } - result = wifi_station_connect(wifi_device, ap.ssid, ap.password, ap.channel); - } - if (result != ERROR_NONE) { - LOG_E(TAG, "Failed to connect (%s)", error_to_string(result)); - } - device_put(wifi_device); - }); -} - -static void disconnectFromAp() { - // Keeps auto-connect from immediately reconnecting - wifi_autoconnect_pause_until_connected(); - getMainDispatcher().dispatch([] { - Device* wifi_device = nullptr; - if (device_get_first_by_type(&WIFI_TYPE, &wifi_device) != ERROR_NONE) { - LOG_W(TAG, "No WiFi device found"); - return; - } - error_t result = wifi_station_disconnect(wifi_device); - if (result != ERROR_NONE) { - LOG_E(TAG, "Failed to disconnect (%s)", error_to_string(result)); - } - device_put(wifi_device); - }); -} - -static void onConnect(const std::string& ssid) { - WifiApSettings settings; - if (wifi_settings_load(ssid.c_str(), &settings) == ERROR_NONE) { - LOG_I(TAG, "Connecting with known credentials"); - connectToAp(settings); - } else { - LOG_I(TAG, "Starting connection dialog"); - wificonnect::start(ssid); - } +static void onConnect(void* userData, const std::string& ssid) { + auto* ctx = static_cast(userData); + ctx->lock(); + ctx->connectRequest = ssid; + ctx->connectRequested = true; + ctx->unlock(); + task_event_group_signal(ctx->eventGroup, ctx->actionBit); } static void onShowApSettings(const std::string& ssid) { wifiapsettings::start(ssid); } -static void onDisconnect() { - disconnectFromAp(); +static void onDisconnect(void* userData) { + auto* ctx = static_cast(userData); + ctx->lock(); + ctx->disconnectRequested = true; + ctx->unlock(); + task_event_group_signal(ctx->eventGroup, ctx->actionBit); } -static void onWifiToggled(bool enabled) { - getMainDispatcher().dispatch([enabled] { - Device* wifi_device = nullptr; - if (device_get_first_by_type(&WIFI_TYPE, &wifi_device) != ERROR_NONE) { - LOG_W(TAG, "No WiFi device found"); - return; - } - error_t result = enabled ? wifi_set_radio_on(wifi_device) : wifi_set_radio_off(wifi_device); - if (result == ERROR_NONE) { - if (wifi_settings_set_enable(enabled) != ERROR_NONE) { - LOG_E(TAG, "Failed to save the Wi-Fi state"); - } - } else { - LOG_E(TAG, "Failed to set radio state (%s)", error_to_string(result)); - } - device_put(wifi_device); - }); +static void onWifiToggled(void* userData, bool enabled) { + auto* ctx = static_cast(userData); + ctx->lock(); + ctx->radioRequest = enabled ? RadioRequest::On : RadioRequest::Off; + ctx->unlock(); + task_event_group_signal(ctx->eventGroup, ctx->actionBit); } static void onConnectToHidden() { @@ -151,6 +111,66 @@ static bool onRefresh() { return started; } +/** Handles the requests of the UI. Turning the radio on blocks, so this runs on the app's task. */ +void processRequests(Context* ctx) { + // Keeps queued requests until the Wi-Fi device is subscribed + if (ctx->wifiDevice == nullptr) { + return; + } + + ctx->lock(); + const auto radio_request = ctx->radioRequest; + const bool disconnect = ctx->disconnectRequested; + const bool connect_requested = ctx->connectRequested; + const std::string ssid = std::move(ctx->connectRequest); + ctx->connectRequested = false; + ctx->radioRequest = RadioRequest::None; + ctx->disconnectRequested = false; + ctx->connectRequest.clear(); + ctx->unlock(); + + if (radio_request != RadioRequest::None) { + const bool enabled = radio_request == RadioRequest::On; + const error_t result = enabled ? wifi_set_radio_on(ctx->wifiDevice) : wifi_set_radio_off(ctx->wifiDevice); + if (result == ERROR_NONE) { + if (wifi_settings_set_enable(enabled) != ERROR_NONE) { + LOG_E(TAG, "Failed to save the Wi-Fi state"); + } + } else { + LOG_E(TAG, "Failed to set radio state (%s)", error_to_string(result)); + } + } + + if (disconnect) { + // Keeps auto-connect from immediately reconnecting + wifi_autoconnect_pause_until_connected(); + const error_t result = wifi_station_disconnect(ctx->wifiDevice); + if (result != ERROR_NONE) { + LOG_E(TAG, "Failed to disconnect (%s)", error_to_string(result)); + } + } + + if (connect_requested) { + WifiApSettings ap; + if (wifi_settings_load(ssid.c_str(), &ap) != ERROR_NONE) { + LOG_I(TAG, "Starting connection dialog"); + wificonnect::start(ssid); + return; + } + LOG_I(TAG, "Connecting with known credentials"); + error_t result = wifi_set_radio_on(ctx->wifiDevice); + if (result == ERROR_NONE) { + if (wifi_settings_set_enable(true) != ERROR_NONE) { + LOG_E(TAG, "Failed to save the Wi-Fi state"); + } + result = wifi_station_connect(ctx->wifiDevice, ap.ssid, ap.password, ap.channel); + } + if (result != ERROR_NONE) { + LOG_E(TAG, "Failed to connect (%s)", error_to_string(result)); + } + } +} + void updateView(Context* ctx, bool rebuildList) { // Same lock order as createWidgets() (called with the LVGL lock already held, per the // window-manager's WindowCreateWidgetsFn contract, then acquiring ctx->mutex) - acquiring @@ -242,6 +262,7 @@ int32_t appMain(int argc, char* argv[]) { Context ctx; ctx.appInstanceId = appInstanceId; ctx.bindings = (Bindings) { + .userData = &ctx, .onWifiToggled = onWifiToggled, .onConnectSsid = onConnect, .onDisconnect = onDisconnect, @@ -257,6 +278,10 @@ int32_t appMain(int argc, char* argv[]) { LOG_W(TAG, "Failed to claim a refresh bit; resurfacing after burial won't repopulate the view"); } + if (task_event_group_claim_bit(&event_group, &ctx.actionBit) != ERROR_NONE) { + LOG_W(TAG, "Failed to claim an action bit; connect and radio requests won't be handled"); + } + AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); @@ -324,6 +349,8 @@ int32_t appMain(int argc, char* argv[]) { if (shouldClose) break; } + processRequests(&ctx); + if (ctx.wifiDevice != nullptr) { WifiEvent wifi_event {}; while (wifi_event_poll(&ctx.wifiEventSub, &wifi_event) == ERROR_NONE) { diff --git a/Tactility/Source/service/statusbar/Statusbar.cpp b/Tactility/Source/service/statusbar/Statusbar.cpp index c69915794..eddabdff3 100644 --- a/Tactility/Source/service/statusbar/Statusbar.cpp +++ b/Tactility/Source/service/statusbar/Statusbar.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include @@ -162,6 +163,8 @@ class StatusbarService final : public Service { bool usb_last_state = false; int8_t install_icon_id; bool install_last_state = false; + /** The number of app_install() calls seen by the previous update, to show installs that ended between two updates */ + uint32_t install_seen_count = app_install_get_started_count(); TaskEventGroup appHubEventGroup {}; AppHubEventSubscription appHubSubscription {}; bool appHubSubscribed = false; @@ -316,7 +319,10 @@ class StatusbarService final : public Service { installingApps.erase(event.app_id); } } - const bool installing = !installingApps.empty(); + const uint32_t startedCount = app_install_get_started_count(); + const bool startedSinceLastUpdate = startedCount != install_seen_count; + install_seen_count = startedCount; + const bool installing = !installingApps.empty() || app_install_is_active() || startedSinceLastUpdate; if (installing != install_last_state) { lvgl_statusbar_icon_set_visibility(install_icon_id, installing); install_last_state = installing;