Refactor app loading and window management (#609)

This commit is contained in:
Ken Van Hoeylandt
2026-08-11 23:40:59 +02:00
committed by GitHub
parent dc3f6104b8
commit 37c507544b
243 changed files with 16034 additions and 10865 deletions
-113
View File
@@ -1,113 +0,0 @@
#include "Tactility/Bundle.h"
namespace tt {
bool Bundle::getBool(const std::string& key) const {
return this->entries.find(key)->second.value_bool;
}
int32_t Bundle::getInt32(const std::string& key) const {
return this->entries.find(key)->second.value_int32;
}
int64_t Bundle::getInt64(const std::string& key) const {
return this->entries.find(key)->second.value_int64;
}
std::string Bundle::getString(const std::string& key) const {
return this->entries.find(key)->second.value_string;
}
bool Bundle::hasBool(const std::string& key) const {
auto entry = this->entries.find(key);
return entry != std::end(this->entries) && entry->second.type == Type::Bool;
}
bool Bundle::hasInt32(const std::string& key) const {
auto entry = this->entries.find(key);
return entry != std::end(this->entries) && entry->second.type == Type::Int32;
}
bool Bundle::hasInt64(const std::string& key) const {
auto entry = this->entries.find(key);
return entry != std::end(this->entries) && entry->second.type == Type::Int64;
}
bool Bundle::hasString(const std::string& key) const {
auto entry = this->entries.find(key);
return entry != std::end(this->entries) && entry->second.type == Type::String;
}
bool Bundle::optBool(const std::string& key, bool& out) const {
auto entry = this->entries.find(key);
if (entry != std::end(this->entries) && entry->second.type == Type::Bool) {
out = entry->second.value_bool;
return true;
} else {
return false;
}
}
bool Bundle::optInt32(const std::string& key, int32_t& out) const {
auto entry = this->entries.find(key);
if (entry != std::end(this->entries) && entry->second.type == Type::Int32) {
out = entry->second.value_int32;
return true;
} else {
return false;
}
}
bool Bundle::optInt64(const std::string& key, int64_t& out) const {
auto entry = this->entries.find(key);
if (entry != std::end(this->entries) && entry->second.type == Type::Int64) {
out = entry->second.value_int64;
return true;
} else {
return false;
}
}
bool Bundle::optString(const std::string& key, std::string& out) const {
auto entry = this->entries.find(key);
if (entry != std::end(this->entries) && entry->second.type == Type::String) {
out = entry->second.value_string;
return true;
} else {
return false;
}
}
void Bundle::putBool(const std::string& key, bool value) {
this->entries[key] = {
.type = Type::Bool,
.value_bool = value,
.value_string = ""
};
}
void Bundle::putInt32(const std::string& key, int32_t value) {
this->entries[key] = {
.type = Type::Int32,
.value_int32 = value,
.value_string = ""
};
}
void Bundle::putInt64(const std::string& key, int64_t value) {
this->entries[key] = {
.type = Type::Int64,
.value_int64 = value,
.value_string = ""
};
}
void Bundle::putString(const std::string& key, const std::string& value) {
this->entries[key] = {
.type = Type::String,
.value_bool = false,
.value_string = value
};
}
} // namespace
@@ -1,6 +1,7 @@
#include <Tactility/Paths.h>
#include <Tactility/DeprecatedPaths.h>
#include "../../Modules/app-module/private/app/private/app_metadata_parsing_internal.h"
#include <Tactility/app/AppManifestParsing.h>
#include <Tactility/MountPoints.h>
#include <format>
@@ -71,12 +72,12 @@ std::string getUserHomePath() {
}
std::string getAppInstallPath(const std::string& appId) {
assert(app::isValidId(appId));
assert(app_metadata_is_valid_id(appId.c_str()));
return std::format("{}/{}", getAppInstallPath(), appId);
}
std::string getAppUserPath(const std::string& appId) {
assert(app::isValidId(appId));
assert(app_metadata_is_valid_id(appId.c_str()));
return std::format("{}/app/{}", getUserHomePath(), appId);
}
-147
View File
@@ -1,147 +0,0 @@
#ifdef ESP_PLATFORM
#include <Tactility/Preferences.h>
#include <Tactility/TactilityCore.h>
#include <nvs_flash.h>
#include <tactility/log.h>
namespace tt {
constexpr auto* TAG = "Preferences";
bool Preferences::optBool(const std::string& key, bool& out) const {
nvs_handle_t handle;
if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) {
LOG_E(TAG, "Failed to open namespace %s", namespace_);
return false;
} else {
uint8_t out_number;
bool success = nvs_get_u8(handle, key.c_str(), &out_number) == ESP_OK;
nvs_close(handle);
if (success) {
out = (bool)out_number;
}
return success;
}
}
bool Preferences::optInt32(const std::string& key, int32_t& out) const {
nvs_handle_t handle;
if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) {
LOG_E(TAG, "Failed to open namespace %s", namespace_);
return false;
} else {
bool success = nvs_get_i32(handle, key.c_str(), &out) == ESP_OK;
nvs_close(handle);
return success;
}
}
bool Preferences::optInt64(const std::string& key, int64_t& out) const {
nvs_handle_t handle;
if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) {
LOG_E(TAG, "Failed to open namespace %s", namespace_);
return false;
} else {
bool success = nvs_get_i64(handle, key.c_str(), &out) == ESP_OK;
nvs_close(handle);
return success;
}
}
bool Preferences::optString(const std::string& key, std::string& out) const {
nvs_handle_t handle;
if (nvs_open(namespace_, NVS_READWRITE, &handle) != ESP_OK) {
LOG_E(TAG, "Failed to open namespace %s", namespace_);
return false;
} else {
size_t out_size = 256;
char* out_data = static_cast<char*>(malloc(out_size));
bool success = nvs_get_str(handle, key.c_str(), out_data, &out_size) == ESP_OK;
nvs_close(handle);
out = out_data;
free(out_data);
return success;
}
}
bool Preferences::hasBool(const std::string& key) const {
bool temp;
return optBool(key, temp);
}
bool Preferences::hasInt32(const std::string& key) const {
int32_t temp;
return optInt32(key, temp);
}
bool Preferences::hasInt64(const std::string& key) const {
int64_t temp;
return optInt64(key, temp);
}
bool Preferences::hasString(const std::string& key) const {
std::string temp;
return optString(key, temp);
}
void Preferences::putBool(const std::string& key, bool value) {
nvs_handle_t handle;
if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) {
if (nvs_set_u8(handle, key.c_str(), value) != ESP_OK) {
LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str());
} else if (nvs_commit(handle) != ESP_OK) {
LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str());
}
nvs_close(handle);
} else {
LOG_E(TAG, "Failed to open namespace %s", namespace_);
}
}
void Preferences::putInt32(const std::string& key, int32_t value) {
nvs_handle_t handle;
if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) {
if (nvs_set_i32(handle, key.c_str(), value) != ESP_OK) {
LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str());
} else if (nvs_commit(handle) != ESP_OK) {
LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str());
}
nvs_close(handle);
} else {
LOG_E(TAG, "Failed to open namespace %s", namespace_);
}
}
void Preferences::putInt64(const std::string& key, int64_t value) {
nvs_handle_t handle;
if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) {
if (nvs_set_i64(handle, key.c_str(), value) != ESP_OK) {
LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str());
} else if (nvs_commit(handle) != ESP_OK) {
LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str());
}
nvs_close(handle);
} else {
LOG_E(TAG, "Failed to open namespace %s", namespace_);
}
}
void Preferences::putString(const std::string& key, const std::string& text) {
nvs_handle_t handle;
if (nvs_open(namespace_, NVS_READWRITE, &handle) == ESP_OK) {
if (nvs_set_str(handle, key.c_str(), text.c_str()) != ESP_OK) {
LOG_E(TAG, "Failed to set %s:%s", namespace_, key.c_str());
} else if (nvs_commit(handle) != ESP_OK) {
LOG_E(TAG, "Failed to commit %s:%s", namespace_, key.c_str());
}
nvs_close(handle);
} else {
LOG_E(TAG, "Failed to open namespace %s", namespace_);
}
}
} // namespace
#endif
-84
View File
@@ -1,84 +0,0 @@
#ifndef ESP_PLATFOM
#include <Tactility/Preferences.h>
#include <Tactility/Bundle.h>
namespace tt {
static Bundle preferences;
/**
* Creates a string that is effectively "namespace:key" so we can create a single map (bundle)
* to store all the key/value pairs.
*
* @param[in] namespace
* @param[in] key
* @param[out] out
*/
std::string get_bundle_key(const std::string& namespace_, const std::string& key) {
return namespace_ + ':' + key;
}
bool Preferences::hasBool(const std::string& key) const {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.hasBool(bundle_key);
}
bool Preferences::hasInt32(const std::string& key) const {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.hasInt32(bundle_key);
}
bool Preferences::hasInt64(const std::string& key) const {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.hasInt64(bundle_key);
}
bool Preferences::hasString(const std::string& key) const {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.hasString(bundle_key);
}
bool Preferences::optBool(const std::string& key, bool& out) const {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.optBool(bundle_key, out);
}
bool Preferences::optInt32(const std::string& key, int32_t& out) const {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.optInt32(bundle_key, out);
}
bool Preferences::optInt64(const std::string& key, int64_t& out) const {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.optInt64(bundle_key, out);
}
bool Preferences::optString(const std::string& key, std::string& out) const {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.optString(bundle_key, out);
}
void Preferences::putBool(const std::string& key, bool value) {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.putBool(bundle_key, value);
}
void Preferences::putInt32(const std::string& key, int32_t value) {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.putInt32(bundle_key, value);
}
void Preferences::putInt64(const std::string& key, int64_t value) {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.putInt64(bundle_key, value);
}
void Preferences::putString(const std::string& key, const std::string& value) {
std::string bundle_key = get_bundle_key(namespace_, key);
return preferences.putString(bundle_key, value);
}
#endif
} // namespace
+183 -135
View File
@@ -1,39 +1,56 @@
#ifdef ESP_PLATFORM
#include <sdkconfig.h>
#include <Tactility/InitEsp.h>
#include <app_esp32/module.h>
#endif
#include <format>
#include <memory>
#include <string>
#include <vector>
#include <app/event.h>
#include <app/install.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/module.h>
#include <Tactility/Tactility.h>
#include <Tactility/CpuAffinity.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/LogMessages.h>
#include <Tactility/MountPoints.h>
#include <Tactility/TactilityConfig.h>
#include <Tactility/bluetooth/Bluetooth.h>
#include <Tactility/CpuAffinity.h>
#include <Tactility/MountPoints.h>
#include <Tactility/app/AppManifestParsing.h>
#include <Tactility/app/AppRegistration.h>
#include <Tactility/file/File.h>
#include <Tactility/LogMessages.h>
#include <Tactility/lvgl/TrackballInit.h>
#include <Tactility/hal/SdCard.h>
#include <Tactility/lvgl/Statusbar.h>
#include <Tactility/lvgl/TrackballInit.h>
#include <Tactility/lvgl/UsbHidInput.h>
#include <Tactility/network/NtpPrivate.h>
#include <Tactility/Paths.h>
#include <Tactility/service/ServiceManifest.h>
#include <Tactility/service/ServiceRegistration.h>
#include <Tactility/service/audio/Audio.h>
#include <Tactility/settings/TimePrivate.h>
#include <Tactility/settings/TouchCalibrationSettings.h>
#include <crypt/module.h>
#include <gps/module.h>
#include <gps_generic/module.h>
#include <gps_meshtastic/module.h>
#include <crypt/module.h>
#include <lvgl/devices/keyboard.h>
#include <lvgl/devices/pointer.h>
#include <lvgl/lvgl.h>
#include <lvgl/module.h>
#include <lvgl/widgets/toolbar.h>
#include <lvgl_window_manager/module.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/concurrent/thread.h>
#include <tactility/device.h>
#include <tactility/drivers/audio_stream.h>
@@ -88,8 +105,6 @@ namespace service {
namespace espnow { extern const ServiceManifest manifest; }
#endif
// Secondary (UI)
namespace gui { extern const ServiceManifest manifest; }
namespace loader { extern const ServiceManifest manifest; }
namespace memorychecker { extern const ServiceManifest manifest; }
namespace statusbar { extern const ServiceManifest manifest; }
#ifdef ESP_PLATFORM
@@ -110,64 +125,66 @@ namespace service {
// region Default apps
// All apps below are converted to the new app-module + window-manager model, so their manifest
// is the new, global ::AppManifest, not this namespace's old tt::app::AppManifest.
namespace app {
namespace addgps { extern const AppManifest manifest; }
namespace alertdialog { extern const AppManifest manifest; }
namespace apphub { extern const AppManifest manifest; }
namespace apphubdetails { extern const AppManifest manifest; }
namespace appdetails { extern const AppManifest manifest; }
namespace applist { extern const AppManifest manifest; }
namespace appsettings { extern const AppManifest manifest; }
namespace audiosettings { extern const AppManifest manifest; }
namespace boot { extern const AppManifest manifest; }
namespace development { extern const AppManifest manifest; }
namespace display { extern const AppManifest manifest; }
namespace kerneldisplay { extern const AppManifest manifest; }
namespace files { extern const AppManifest manifest; }
namespace fileselection { extern const AppManifest manifest; }
namespace gpssettings { extern const AppManifest manifest; }
namespace grovesettings { extern const AppManifest manifest; }
namespace i2cscanner { extern const AppManifest manifest; }
namespace imageviewer { extern const AppManifest manifest; }
namespace inputdialog { extern const AppManifest manifest; }
namespace launcher { extern const AppManifest manifest; }
namespace localesettings { extern const AppManifest manifest; }
namespace notes { extern const AppManifest manifest; }
namespace power { extern const AppManifest manifest; }
namespace poweroff { extern const AppManifest manifest; }
namespace selectiondialog { extern const AppManifest manifest; }
namespace settings { extern const AppManifest manifest; }
namespace setup { extern const AppManifest manifest; }
namespace systeminfo { extern const AppManifest manifest; }
namespace timedatesettings { extern const AppManifest manifest; }
namespace addgps { extern const ::AppManifest manifest; }
namespace alertdialog { extern const ::AppManifest manifest; }
namespace apphub { extern const ::AppManifest manifest; }
namespace apphubdetails { extern const ::AppManifest manifest; }
namespace appdetails { extern const ::AppManifest manifest; }
namespace applist { extern const ::AppManifest manifest; }
namespace appsettings { extern const ::AppManifest manifest; }
namespace audiosettings { extern const ::AppManifest manifest; }
namespace boot { extern const ::AppManifest manifest; }
namespace development { extern const ::AppManifest manifest; }
namespace display { extern const ::AppManifest manifest; }
namespace kerneldisplay { extern const ::AppManifest manifest; }
namespace files { extern const ::AppManifest manifest; }
namespace fileselection { extern const ::AppManifest manifest; }
namespace gpssettings { extern const ::AppManifest manifest; }
namespace grovesettings { extern const ::AppManifest manifest; }
namespace i2cscanner { extern const ::AppManifest manifest; }
namespace imageviewer { extern const ::AppManifest manifest; }
namespace inputdialog { extern const ::AppManifest manifest; }
namespace launcher { extern const ::AppManifest manifest; }
namespace localesettings { extern const ::AppManifest manifest; }
namespace notes { extern const ::AppManifest manifest; }
namespace power { extern const ::AppManifest manifest; }
namespace poweroff { extern const ::AppManifest manifest; }
namespace selectiondialog { extern const ::AppManifest manifest; }
namespace settings { extern const ::AppManifest manifest; }
namespace setup { extern const ::AppManifest manifest; }
namespace systeminfo { extern const ::AppManifest manifest; }
namespace timedatesettings { extern const ::AppManifest manifest; }
#ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
namespace touchcalibration { extern const AppManifest manifest; }
namespace touchcalibration { extern const ::AppManifest manifest; }
#endif
namespace timezone { extern const AppManifest manifest; }
namespace usbsettings { extern const AppManifest manifest; }
namespace btmanage { extern const AppManifest manifest; }
namespace btpeersettings { extern const AppManifest manifest; }
namespace wifiapsettings { extern const AppManifest manifest; }
namespace wificonnect { extern const AppManifest manifest; }
namespace wifimanage { extern const AppManifest manifest; }
namespace timezone { extern const ::AppManifest manifest; }
namespace usbsettings { extern const ::AppManifest manifest; }
namespace btmanage { extern const ::AppManifest manifest; }
namespace btpeersettings { extern const ::AppManifest manifest; }
namespace wifiapsettings { extern const ::AppManifest manifest; }
namespace wificonnect { extern const ::AppManifest manifest; }
namespace wifimanage { extern const ::AppManifest manifest; }
#ifdef ESP_PLATFORM
namespace apwebserver { extern const AppManifest manifest; }
namespace crashdiagnostics { extern const AppManifest manifest; }
namespace webserversettings { extern const AppManifest manifest; }
namespace apwebserver { extern const ::AppManifest manifest; }
namespace crashdiagnostics { extern const ::AppManifest manifest; }
namespace webserversettings { extern const ::AppManifest manifest; }
#if CONFIG_TT_TDECK_WORKAROUND == 1
namespace keyboardsettings { extern const AppManifest manifest; } // T-Deck only for now
namespace keyboardsettings { extern const ::AppManifest manifest; } // T-Deck only for now
#endif
#endif
namespace trackballsettings { extern const AppManifest manifest; } // T-Deck only for now
namespace trackballsettings { extern const ::AppManifest manifest; } // T-Deck only for now
#if TT_FEATURE_SCREENSHOT_ENABLED
namespace screenshot { extern const AppManifest manifest; }
namespace screenshot { extern const ::AppManifest manifest; }
#endif
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
namespace chat { extern const AppManifest manifest; }
namespace chat { extern const ::AppManifest manifest; }
#endif
}
@@ -177,118 +194,90 @@ namespace app {
static void registerInternalApps() {
LOG_I(TAG, "Registering internal apps");
addAppManifest(app::alertdialog::manifest);
addAppManifest(app::appdetails::manifest);
addAppManifest(app::apphub::manifest);
addAppManifest(app::apphubdetails::manifest);
addAppManifest(app::applist::manifest);
addAppManifest(app::appsettings::manifest);
app_manager_add(&app::alertdialog::manifest);
app_manager_add(&app::appdetails::manifest);
app_manager_add(&app::apphub::manifest);
app_manager_add(&app::apphubdetails::manifest);
app_manager_add(&app::applist::manifest);
app_manager_add(&app::appsettings::manifest);
if (service::audio::isAvailable()) {
addAppManifest(app::audiosettings::manifest);
app_manager_add(&app::audiosettings::manifest);
}
if (device_exists_of_type(&DISPLAY_TYPE)) {
addAppManifest(app::kerneldisplay::manifest);
app_manager_add(&app::kerneldisplay::manifest);
}
addAppManifest(app::files::manifest);
addAppManifest(app::fileselection::manifest);
addAppManifest(app::i2cscanner::manifest);
addAppManifest(app::imageviewer::manifest);
addAppManifest(app::inputdialog::manifest);
addAppManifest(app::launcher::manifest);
addAppManifest(app::localesettings::manifest);
addAppManifest(app::notes::manifest);
app_manager_add(&app::files::manifest);
app_manager_add(&app::fileselection::manifest);
app_manager_add(&app::i2cscanner::manifest);
app_manager_add(&app::imageviewer::manifest);
app_manager_add(&app::inputdialog::manifest);
app_manager_add(&app::launcher::manifest);
app_manager_add(&app::localesettings::manifest);
app_manager_add(&app::notes::manifest);
if (device_exists_of_type(&POWER_SUPPLY_TYPE)) {
addAppManifest(app::poweroff::manifest);
app_manager_add(&app::poweroff::manifest);
}
addAppManifest(app::settings::manifest);
addAppManifest(app::selectiondialog::manifest);
addAppManifest(app::setup::manifest);
addAppManifest(app::systeminfo::manifest);
addAppManifest(app::timedatesettings::manifest);
app_manager_add(&app::settings::manifest);
app_manager_add(&app::selectiondialog::manifest);
app_manager_add(&app::setup::manifest);
app_manager_add(&app::systeminfo::manifest);
app_manager_add(&app::timedatesettings::manifest);
#ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
addAppManifest(app::touchcalibration::manifest);
app_manager_add(&app::touchcalibration::manifest);
#endif
addAppManifest(app::timezone::manifest);
addAppManifest(app::wifiapsettings::manifest);
addAppManifest(app::wificonnect::manifest);
addAppManifest(app::wifimanage::manifest);
app_manager_add(&app::timezone::manifest);
app_manager_add(&app::wifiapsettings::manifest);
app_manager_add(&app::wificonnect::manifest);
app_manager_add(&app::wifimanage::manifest);
#ifdef ESP_PLATFORM
addAppManifest(app::apwebserver::manifest);
addAppManifest(app::webserversettings::manifest);
addAppManifest(app::crashdiagnostics::manifest);
addAppManifest(app::development::manifest);
app_manager_add(&app::apwebserver::manifest);
app_manager_add(&app::webserversettings::manifest);
app_manager_add(&app::crashdiagnostics::manifest);
app_manager_add(&app::development::manifest);
#if defined(CONFIG_TT_TDECK_WORKAROUND)
addAppManifest(app::keyboardsettings::manifest);
app_manager_add(&app::keyboardsettings::manifest);
#endif
#endif
if (device_exists_of_type(&TRACKBALL_TYPE)) {
addAppManifest(app::trackballsettings::manifest);
app_manager_add(&app::trackballsettings::manifest);
}
#if defined(CONFIG_TINYUSB_MSC_ENABLED) && CONFIG_TINYUSB_MSC_ENABLED
addAppManifest(app::usbsettings::manifest);
app_manager_add(&app::usbsettings::manifest);
#endif
#if TT_FEATURE_SCREENSHOT_ENABLED
addAppManifest(app::screenshot::manifest);
app_manager_add(&app::screenshot::manifest);
#endif
#if defined(CONFIG_SOC_WIFI_SUPPORTED) || defined(CONFIG_SLAVE_SOC_WIFI_SUPPORTED)
addAppManifest(app::chat::manifest);
app_manager_add(&app::chat::manifest);
#endif
if (device_exists_of_type(&GROVE_TYPE)) {
addAppManifest(app::grovesettings::manifest);
app_manager_add(&app::grovesettings::manifest);
}
if (device_exists_of_type(&UART_CONTROLLER_TYPE) || device_exists_of_type(&GROVE_TYPE)) {
addAppManifest(app::addgps::manifest);
addAppManifest(app::gpssettings::manifest);
app_manager_add(&app::addgps::manifest);
app_manager_add(&app::gpssettings::manifest);
}
if (device_exists_of_type(&POWER_SUPPLY_TYPE)) {
addAppManifest(app::power::manifest);
app_manager_add(&app::power::manifest);
}
#if defined(CONFIG_BT_ENABLED) && CONFIG_BT_ENABLED
addAppManifest(app::btmanage::manifest);
addAppManifest(app::btpeersettings::manifest);
app_manager_add(&app::btmanage::manifest);
app_manager_add(&app::btpeersettings::manifest);
#endif
}
static void registerInstalledApp(std::string path) {
LOG_I(TAG, "Registering app at %s", path.c_str());
std::string manifest_path = path + "/manifest.properties";
if (!file::isFile(manifest_path)) {
LOG_E(TAG, "Manifest not found at %s", manifest_path.c_str());
return;
}
app::AppManifest manifest;
if (!app::parseManifest(manifest_path, manifest)) {
LOG_E(TAG, "Failed to parse manifest at %s", manifest_path.c_str());
return;
}
manifest.appCategory = app::Category::User;
manifest.appLocation = app::Location::external(path);
app::addAppManifest(manifest);
}
static void registerInstalledApps(const std::string& path) {
LOG_I(TAG, "Registering apps from %s", path.c_str());
file::listDirectory(path, [&path](const auto& entry) {
auto absolute_path = std::format("{}/{}", path, entry.d_name);
if (file::isDirectory(absolute_path)) {
registerInstalledApp(absolute_path);
}
});
}
// Registers every mounted filesystem's app install directory with app-module (see
// app_manager_install_path_add()/app_manager_install_path_scan() in app/install.h), then scans
// them once to register whatever's already installed there.
static void registerInstalledAppsFromFileSystems() {
file_system_for_each(nullptr, [](auto* fs, void* context) {
if (!file_system_is_mounted(fs)) return true;
@@ -296,11 +285,12 @@ static void registerInstalledAppsFromFileSystems() {
if (file_system_get_path(fs, path, sizeof(path)) != ERROR_NONE) return true;
const auto app_path = std::format("{}/tactility/app", path);
if (!app_path.starts_with(file::MOUNT_POINT_SYSTEM) && file::isDirectory(app_path)) {
LOG_I(TAG, "Registering apps from %s", app_path.c_str());
registerInstalledApps(app_path);
LOG_I(TAG, "Registering install path %s", app_path.c_str());
app_manager_install_path_add(app_path.c_str());
}
return true;
});
app_manager_install_path_scan();
}
static void registerAndStartServices() {
@@ -319,7 +309,6 @@ static void registerAndStartServices() {
#ifdef ESP_PLATFORM
addService(service::webserver::manifest);
#endif
addService(service::loader::manifest);
#if defined(ESP_PLATFORM)
if (device_exists_of_type(&RTC_TYPE)) {
addService(service::rtctime::manifest);
@@ -360,7 +349,49 @@ void registerApps() {
}
static void stopAppFromToolbar(lv_event_t*) {
app::stop();
// Default nav action for any toolbar that doesn't override it itself. Prefer the topmost
// new-model app if one is showing; fall back to the old system otherwise (this is what
// every not-yet-converted app's toolbar still relies on).
AppInstanceId topmost = 0;
check(app_manager_get_topmost_instance_id(&topmost) == ERROR_NONE);
// Async, non-blocking - must NOT call app_manager_stop() directly here: that
// bound-waits (thread_join) for the app's own thread to finish, which needs the LVGL
// lock to clean up - but this callback runs ON the LVGL task, which would deadlock
// against itself.
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(topmost, &event);
}
// The on-screen keyboard widget itself, constructed during windowManagerScreenInit
static LvglSoftwareKeyboard softwareKeyboard { .object = nullptr };
static lv_obj_t* windowManagerScreenInit(lv_obj_t* root) {
lv_obj_t* vertical_container = lv_obj_create(root);
lv_obj_set_size(vertical_container, LV_PCT(100), LV_PCT(100));
lv_obj_set_flex_flow(vertical_container, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(vertical_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(vertical_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_color(vertical_container, lv_color_black(), LV_STATE_DEFAULT);
lv_obj_set_style_border_width(vertical_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_radius(vertical_container, 0, LV_STATE_DEFAULT);
lv_obj_remove_flag(vertical_container, LV_OBJ_FLAG_SCROLLABLE);
lvgl::statusbar_create(vertical_container);
auto* app_container = lv_obj_create(vertical_container);
lv_obj_set_style_pad_all(app_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(app_container, 0, LV_STATE_DEFAULT);
lv_obj_set_width(app_container, LV_PCT(100));
lv_obj_set_flex_grow(app_container, 1);
lv_obj_set_flex_flow(app_container, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(app_container, LV_OBJ_FLAG_SCROLLABLE);
// Parented to root (not app_container/vertical_container) so it overlays on top of
// everything, including the statusbar, regardless of which app is showing. Hidden until a
// focused textarea shows it (see lvgl_keyboard_add_textarea()/textarea_show_keyboard()).
lvgl_software_keyboard_construct(&softwareKeyboard, root);
return app_container;
}
#ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
@@ -391,10 +422,12 @@ static void applySavedTouchCalibration() {
#endif // CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
static void onLvglStarted() {
window_manager_configure(windowManagerScreenInit);
check(module_ensure_started(&lvgl_window_manager_module) == ERROR_NONE);
ToolbarConfig toolbar_config = { .nav_action_callback = stopAppFromToolbar };
lvgl_toolbar_configure(&toolbar_config);
addService(service::gui::manifest);
addService(service::statusbar::manifest);
addService(service::memorychecker::manifest);
#if defined(ESP_PLATFORM)
@@ -407,6 +440,7 @@ static void onLvglStarted() {
addService(service::screenshot::manifest);
#endif
lvgl::startUsbHidInput();
lvgl::initTrackball();
#ifdef CONFIG_TT_TOUCH_CALIBRATION_SUPPORTED
@@ -417,6 +451,14 @@ static void onLvglStarted() {
}
static void onLvglStopped() {
if (softwareKeyboard.object != nullptr) {
lvgl_software_keyboard_destruct(&softwareKeyboard);
}
module_stop(&lvgl_window_manager_module);
lvgl::stopUsbHidInput();
#if TT_FEATURE_SCREENSHOT_ENABLED
check(service::removeService(service::screenshot::manifest.id));
#endif
@@ -428,7 +470,6 @@ static void onLvglStopped() {
#endif
check(service::removeService(service::memorychecker::manifest.id));
check(service::removeService(service::statusbar::manifest.id));
check(service::removeService(service::gui::manifest.id));
memory_print_stats();
}
@@ -446,6 +487,11 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) {
check(module_ensure_started(&gps_module) == ERROR_NONE);
check(module_ensure_started(&gps_generic_module) == ERROR_NONE);
check(module_ensure_started(&gps_meshtastic_module) == ERROR_NONE);
// Registers the APP_LOCATION_MEMORY app loader (boot/launcher need it below).
check(module_ensure_started(&app_module) == ERROR_NONE);
#ifdef ESP_PLATFORM
check(module_ensure_started(&app_esp32_module) == ERROR_NONE);
#endif
#ifdef ESP_PLATFORM
initEsp();
@@ -481,9 +527,11 @@ void run(Module* const dtsModules[], const DtsDevice dtsDevices[]) {
LOG_I(TAG, "Core systems ready");
LOG_I(TAG, "Starting boot app");
// The boot app takes care of registering system apps, user services and user apps
addAppManifest(app::boot::manifest);
app::start(app::boot::manifest.appId);
// The boot app takes care of registering system apps, user services and user apps.
// It's a new-model (app-module + window-manager) app now, replacing the old app::start().
app_manager_add(&app::boot::manifest);
uint32_t boot_instance_id = 0;
app_manager_start(app::boot::manifest.id, &boot_instance_id);
LOG_I(TAG, "Main dispatcher ready");
while (true) {
-49
View File
@@ -1,49 +0,0 @@
#include <Tactility/app/App.h>
#include <Tactility/service/loader/Loader.h>
namespace tt::app {
constexpr auto* TAG = "App";
LaunchId start(const std::string& id, std::shared_ptr<const Bundle> parameters) {
const auto service = service::loader::findLoaderService();
assert(service != nullptr);
return service->start(id, std::move(parameters));
}
void stop() {
const auto service = service::loader::findLoaderService();
assert(service != nullptr);
service->stopTop();
}
void stop(const std::string& id) {
const auto service = service::loader::findLoaderService();
assert(service != nullptr);
service->stopTop(id);
}
void stopAll(const std::string& id) {
const auto service = service::loader::findLoaderService();
assert(service != nullptr);
service->stopAll(id);
}
bool isRunning(const std::string& id) {
const auto service = service::loader::findLoaderService();
assert(service != nullptr);
return service->isRunning(id);
}
std::shared_ptr<AppContext> getCurrentAppContext() {
const auto service = service::loader::findLoaderService();
assert(service != nullptr);
return service->getCurrentAppContext();
}
std::shared_ptr<App> getCurrentApp() {
const auto app_context = getCurrentAppContext();
return (app_context != nullptr) ? app_context->getApp() : nullptr;
}
}
-206
View File
@@ -1,206 +0,0 @@
#include <Tactility/app/App.h>
#include <Tactility/app/AppManifestParsing.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/AppRegistration.h>
#include <Tactility/file/File.h>
#include <Tactility/Paths.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <format>
#include <map>
#include <unistd.h>
#include <minitar.h>
#include <tactility/filesystem/file_mutex.h>
#include <tactility/log.h>
namespace tt::app {
constexpr auto* TAG = "App";
static bool untarFile(minitar* mp, const minitar_entry* entry, const std::string& destinationPath) {
const auto absolute_path = destinationPath + "/" + entry->metadata.path;
if (!file::findOrCreateDirectory(destinationPath, 0777)) {
LOG_E(TAG, "Can't find or create directory %s", destinationPath.c_str());
return false;
}
// minitar_read_contents(&mp, &entry, file_buffer, entry.metadata.size);
if (!minitar_read_contents_to_file(mp, entry, absolute_path.c_str())) {
LOG_E(TAG, "Failed to write data to %s", absolute_path.c_str());
return false;
}
// Note: fchmod() doesn't exist on ESP-IDF and chmod() does nothing on that platform
if (chmod(absolute_path.c_str(), entry->metadata.mode) < 0) {
return false;
}
return true;
}
static bool untarDirectory(const minitar_entry* entry, const std::string& destinationPath) {
auto absolute_path = destinationPath + "/" + entry->metadata.path;
if (!file::findOrCreateDirectory(absolute_path, 0777)) return false;
return true;
}
static bool untar(const std::string& tarPath, const std::string& destinationPath) {
minitar mp;
if (minitar_open(tarPath.c_str(), &mp) != 0) {
perror(tarPath.c_str());
return 1;
}
bool success = true;
minitar_entry entry;
do {
if (minitar_read_entry(&mp, &entry) == 0) {
LOG_I(TAG, "Extracting %s", entry.metadata.path);
if (entry.metadata.type == MTAR_DIRECTORY) {
if (!strcmp(entry.metadata.name, ".") || !strcmp(entry.metadata.name, "..") || !strcmp(entry.metadata.name, "/")) continue;
if (!untarDirectory(&entry, destinationPath)) {
LOG_E(TAG, "Failed to create directory %s/%s: %s", destinationPath.c_str(), entry.metadata.name, strerror(errno));
success = false;
break;
}
} else if (entry.metadata.type == MTAR_REGULAR) {
if (!untarFile(&mp, &entry, destinationPath)) {
LOG_E(TAG, "Failed to extract file %s: %s", entry.metadata.path, strerror(errno));
success = false;
break;
}
} else if (entry.metadata.type == MTAR_SYMLINK) {
LOG_E(TAG, "SYMLINK not supported");
} else if (entry.metadata.type == MTAR_HARDLINK) {
LOG_E(TAG, "HARDLINK not supported");
} else if (entry.metadata.type == MTAR_FIFO) {
LOG_E(TAG, "FIFO not supported");
} else if (entry.metadata.type == MTAR_BLKDEV) {
LOG_E(TAG, "BLKDEV not supported");
} else if (entry.metadata.type == MTAR_CHRDEV) {
LOG_E(TAG, "CHRDEV not supported");
} else {
LOG_E(TAG, "Unknown entry type: %d", static_cast<int>(entry.metadata.type));
success = false;
break;
}
} else break;
} while (true);
minitar_close(&mp);
return success;
}
void cleanupInstallDirectory(const std::string& path) {
if (!file::deleteRecursively(path)) {
LOG_W(TAG, "Failed to delete existing installation at %s", path.c_str());
}
}
bool install(const std::string& path) {
// We lock and unlock frequently because SPI SD card devices share
// the lock with the display. We don't want to lock the display for very long.
auto app_parent_path = getAppInstallPath();
LOG_I(TAG, "Installing app %s to %s", path.c_str(), app_parent_path.c_str());
auto filename = file::getLastPathSegment(path);
const std::string app_target_path = std::format("{}/{}", app_parent_path, filename);
if (file::isDirectory(app_target_path) && !file::deleteRecursively(app_target_path)) {
LOG_W(TAG, "Failed to delete %s", app_target_path.c_str());
}
if (!file::findOrCreateDirectory(app_target_path, 0777)) {
LOG_I(TAG, "Failed to create directory %s", app_target_path.c_str());
return false;
}
FileMutex target_path_mutex;
file_mutex_get(&target_path_mutex, app_parent_path.c_str());
FileMutex source_path_mutex;
file_mutex_get(&source_path_mutex, path.c_str());
file_mutex_lock(&target_path_mutex);
file_mutex_lock(&source_path_mutex);
LOG_I(TAG, "Extracting app from %s to %s", path.c_str(), app_target_path.c_str());
bool untar_success = untar(path, app_target_path);
file_mutex_unlock(&source_path_mutex);
file_mutex_unlock(&target_path_mutex);
if (!untar_success) {
LOG_E(TAG, "Failed to extract");
return false;
}
auto manifest_path = app_target_path + "/manifest.properties";
if (!file::isFile(manifest_path)) {
LOG_E(TAG, "Manifest not found at %s", manifest_path.c_str());
cleanupInstallDirectory(app_target_path);
return false;
}
AppManifest manifest;
if (!parseManifest(manifest_path, manifest)) {
LOG_W(TAG, "Invalid manifest");
cleanupInstallDirectory(app_target_path);
return false;
}
// If the app was already running, then stop it
if (isRunning(manifest.appId)) {
stopAll(manifest.appId);
}
const std::string renamed_target_path = std::format("{}/{}", app_parent_path, manifest.appId);
if (file::isDirectory(renamed_target_path)) {
if (!file::deleteRecursively(renamed_target_path)) {
LOG_W(TAG, "Failed to delete existing installation at %s", renamed_target_path.c_str());
cleanupInstallDirectory(app_target_path);
return false;
}
}
file_mutex_lock(&target_path_mutex);
bool rename_success = rename(app_target_path.c_str(), renamed_target_path.c_str()) == 0;
file_mutex_unlock(&target_path_mutex);
if (!rename_success) {
LOG_E(TAG, R"(Failed to rename "%s" to "%s")", app_target_path.c_str(), manifest.appId.c_str());
cleanupInstallDirectory(app_target_path);
return false;
}
manifest.appLocation = Location::external(renamed_target_path);
addAppManifest(manifest);
return true;
}
bool uninstall(const std::string& appId) {
LOG_I(TAG, "Uninstalling app %s", appId.c_str());
// If the app was running, then stop it
if (isRunning(appId)) {
stopAll(appId);
}
auto app_path = getAppInstallPath(appId);
if (!file::isDirectory(app_path)) {
LOG_E(TAG, "App %s not found at %s", appId.c_str(), app_path.c_str());
return false;
}
if (!file::deleteRecursively(app_path)) {
return false;
}
if (!removeAppManifest(appId)) {
LOG_W(TAG, "Failed to remove app %s from registry", appId.c_str());
}
return true;
}
} // namespace
-55
View File
@@ -1,55 +0,0 @@
#include <Tactility/app/AppInstance.h>
#include <Tactility/app/AppPaths.h>
namespace tt::app {
void AppInstance::setState(State newState) {
mutex.lock();
state = newState;
mutex.unlock();
}
State AppInstance::getState() const {
mutex.lock();
auto result = state;
mutex.unlock();
return result;
}
/** TODO: Make this thread-safe.
* In practice, the bundle is writeable, so someone could be writing to it
* while it is being accessed from another thread.
* Consider creating MutableBundle vs Bundle.
* Consider not exposing bundle, but expose `app_get_bundle_int(key)` methods with locking in it.
*/
const AppManifest& AppInstance::getManifest() const {
assert(manifest != nullptr);
return *manifest;
}
Flags AppInstance::getFlags() const {
mutex.lock();
auto result = flags;
mutex.unlock();
return result;
}
void AppInstance::setFlags(Flags newFlags) {
mutex.lock();
flags = newFlags;
mutex.unlock();
}
std::shared_ptr<const Bundle> AppInstance::getParameters() const {
mutex.lock();
std::shared_ptr<const Bundle> result = parameters;
mutex.unlock();
return result;
}
std::unique_ptr<AppPaths> AppInstance::getPaths() const {
assert(manifest != nullptr);
return std::make_unique<AppPaths>(*manifest);
}
} // namespace
@@ -1,97 +0,0 @@
#include <Tactility/app/AppManifestParsing.h>
#include <Tactility/app/AppManifestParsingInternal.h>
#include <Tactility/StringUtils.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <algorithm>
#include <tactility/log.h>
namespace tt::app {
constexpr auto* TAG = "AppManifest";
constexpr bool validateString(const std::string& value, const std::function<bool(char)>& isValidChar) {
return std::ranges::all_of(value, isValidChar);
}
bool getValueFromManifest(const std::map<std::string, std::string>& map, const std::string& key, std::string& output) {
const auto iterator = map.find(key);
if (iterator == map.end()) {
LOG_E(TAG, "Failed to find %s in manifest", key.c_str());
return false;
}
output = iterator->second;
return true;
}
bool isValidId(const std::string& id) {
return id.size() >= 5 && validateString(id, [](const char c) {
return std::isalnum(c) != 0 || c == '.';
});
}
bool isValidManifestVersion(const std::string& version) {
return !version.empty() && validateString(version, [](const char c) {
return std::isalnum(c) != 0 || c == '.';
});
}
bool isValidAppVersionName(const std::string& version) {
return !version.empty() && validateString(version, [](const char c) {
return std::isalnum(c) != 0 || c == '.' || c == '-' || c == '_';
});
}
bool isValidAppVersionCode(const std::string& version) {
return !version.empty() && validateString(version, [](const char c) {
return std::isdigit(c) != 0;
});
}
bool isValidName(const std::string& name) {
return name.size() >= 2 && validateString(name, [](const char c) {
return std::isalnum(c) != 0 || c == ' ' || c == '-';
});
}
/** The V1 format's first line is always the literal "[manifest]" section header; V2 files are flat from the first line onward. */
static bool detectIsV1Format(const std::string& filePath) {
std::string first_line;
bool got_first_line = false;
file::readLines(filePath, true, [&first_line, &got_first_line](const char* line) {
if (!got_first_line) {
first_line = string::trim(std::string(line), " \t\r\n");
got_first_line = true;
}
});
return first_line == "[manifest]";
}
bool parseManifest(const std::string& filePath, AppManifest& manifest) {
LOG_I(TAG, "Parsing manifest %s", filePath.c_str());
bool is_v1_format = detectIsV1Format(filePath);
std::map<std::string, std::string> properties;
if (!file::loadPropertiesFile(filePath, properties)) {
LOG_E(TAG, "Failed to load manifest at %s", filePath.c_str());
return false;
}
bool success = is_v1_format
? parseManifestV1(properties, manifest)
: parseManifestV2(properties, manifest);
if (!success) {
return false;
}
manifest.appCategory = Category::User;
manifest.appLocation = Location::external("");
return true;
}
}
@@ -1,77 +0,0 @@
#include <Tactility/app/AppManifestParsing.h>
#include <Tactility/app/AppManifestParsingInternal.h>
#include <tactility/log.h>
namespace tt::app {
constexpr auto* TAG = "AppManifestV1";
bool parseManifestV1(const std::map<std::string, std::string>& map, AppManifest& manifest) {
// [manifest]
std::string manifest_version;
if (!getValueFromManifest(map, "[manifest]version", manifest_version)) {
return false;
}
if (!isValidManifestVersion(manifest_version)) {
LOG_E(TAG, "Invalid version");
return false;
}
// [app]
if (!getValueFromManifest(map, "[app]id", manifest.appId)) {
return false;
}
if (!isValidId(manifest.appId)) {
LOG_E(TAG, "Invalid app id");
return false;
}
if (!getValueFromManifest(map, "[app]name", manifest.appName)) {
return false;
}
if (!isValidName(manifest.appName)) {
LOG_E(TAG, "Invalid app name");
return false;
}
if (!getValueFromManifest(map, "[app]versionName", manifest.appVersionName)) {
return false;
}
if (!isValidAppVersionName(manifest.appVersionName)) {
LOG_E(TAG, "Invalid app version name");
return false;
}
std::string version_code_string;
if (!getValueFromManifest(map, "[app]versionCode", version_code_string)) {
return false;
}
if (!isValidAppVersionCode(version_code_string)) {
LOG_E(TAG, "Invalid app version code");
return false;
}
manifest.appVersionCode = std::stoull(version_code_string);
// [target]
if (!getValueFromManifest(map, "[target]sdk", manifest.targetSdk)) {
return false;
}
if (!getValueFromManifest(map, "[target]platforms", manifest.targetPlatforms)) {
return false;
}
return true;
}
}
@@ -1,77 +0,0 @@
#include <Tactility/app/AppManifestParsing.h>
#include <Tactility/app/AppManifestParsingInternal.h>
#include <tactility/log.h>
namespace tt::app {
constexpr auto* TAG = "AppManifestV2";
bool parseManifestV2(const std::map<std::string, std::string>& map, AppManifest& manifest) {
// manifest
std::string manifest_version;
if (!getValueFromManifest(map, "manifest.version", manifest_version)) {
return false;
}
if (!isValidManifestVersion(manifest_version)) {
LOG_E(TAG, "Invalid version");
return false;
}
// app
if (!getValueFromManifest(map, "app.id", manifest.appId)) {
return false;
}
if (!isValidId(manifest.appId)) {
LOG_E(TAG, "Invalid app id");
return false;
}
if (!getValueFromManifest(map, "app.name", manifest.appName)) {
return false;
}
if (!isValidName(manifest.appName)) {
LOG_E(TAG, "Invalid app name");
return false;
}
if (!getValueFromManifest(map, "app.version.name", manifest.appVersionName)) {
return false;
}
if (!isValidAppVersionName(manifest.appVersionName)) {
LOG_E(TAG, "Invalid app version name");
return false;
}
std::string version_code_string;
if (!getValueFromManifest(map, "app.version.code", version_code_string)) {
return false;
}
if (!isValidAppVersionCode(version_code_string)) {
LOG_E(TAG, "Invalid app version code");
return false;
}
manifest.appVersionCode = std::stoull(version_code_string);
// target
if (!getValueFromManifest(map, "target.sdk", manifest.targetSdk)) {
return false;
}
if (!getValueFromManifest(map, "target.platforms", manifest.targetPlatforms)) {
return false;
}
return true;
}
}
-44
View File
@@ -1,44 +0,0 @@
#include <Tactility/app/AppPaths.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/MountPoints.h>
#include <Tactility/file/File.h>
#include <format>
#ifdef ESP_PLATFORM
constexpr auto PARTITION_PREFIX = std::string("/");
#else
constexpr auto PARTITION_PREFIX = std::string("");
#endif
namespace tt::app {
std::string AppPaths::getUserDataPath() const {
if (manifest.appLocation.isInternal()) {
return std::format("{}{}/tactility/user/app/{}", PARTITION_PREFIX, file::DATA_PARTITION_NAME, manifest.appId);
} else {
return std::format("{}/tactility/user/app/{}", file::getFirstPathSegment(manifest.appLocation.getPath()), manifest.appId);
}
}
std::string AppPaths::getUserDataPath(const std::string& childPath) const {
assert(!childPath.starts_with('/'));
return std::format("{}/{}", getUserDataPath(), childPath);
}
std::string AppPaths::getAssetsPath() const {
if (manifest.appLocation.isInternal()) {
return std::format("{}{}/app/{}/assets", PARTITION_PREFIX, file::SYSTEM_PARTITION_NAME, manifest.appId);
} else {
return std::format("{}/assets", manifest.appLocation.getPath());
}
}
std::string AppPaths::getAssetsPath(const std::string& childPath) const {
assert(!childPath.starts_with('/'));
return std::format("{}/{}", getAssetsPath(), childPath);
}
}
-63
View File
@@ -1,63 +0,0 @@
#include <Tactility/app/AppRegistration.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/Mutex.h>
#include <unordered_map>
#include <Tactility/file/File.h>
#include <tactility/log.h>
namespace tt::app {
constexpr auto* TAG = "AppRegistration";
typedef std::unordered_map<std::string, std::shared_ptr<AppManifest>> AppManifestMap;
static AppManifestMap app_manifest_map;
static Mutex hash_mutex;
void addAppManifest(const AppManifest& manifest) {
LOG_I(TAG, "Registering manifest %s", manifest.appId.c_str());
hash_mutex.lock();
if (app_manifest_map.contains(manifest.appId)) {
LOG_W(TAG, "Overwriting existing manifest for %s", manifest.appId.c_str());
}
app_manifest_map[manifest.appId] = std::make_shared<AppManifest>(manifest);
hash_mutex.unlock();
}
bool removeAppManifest(const std::string& id) {
LOG_I(TAG, "Removing manifest for %s", id.c_str());
auto lock = hash_mutex.asScopedLock();
lock.lock();
return app_manifest_map.erase(id) == 1;
}
std::shared_ptr<AppManifest> findAppManifestById(const std::string& id) {
hash_mutex.lock();
auto result = app_manifest_map.find(id);
hash_mutex.unlock();
if (result != app_manifest_map.end()) {
return result->second;
} else {
return nullptr;
}
}
std::vector<std::shared_ptr<AppManifest>> getAppManifests() {
std::vector<std::shared_ptr<AppManifest>> manifests;
hash_mutex.lock();
for (const auto& item: app_manifest_map) {
manifests.push_back(item.second);
}
hash_mutex.unlock();
return manifests;
}
} // namespace
-235
View File
@@ -1,235 +0,0 @@
#ifdef ESP_PLATFORM
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/app/ElfApp.h>
#include <Tactility/file/File.h>
#include <Tactility/file/FileLock.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/StringUtils.h>
#include <esp_elf.h>
#include <string>
#include <tactility/log.h>
#include <utility>
namespace tt::app {
constexpr auto* TAG = "ElfApp";
static std::string getErrorCodeString(int error_code) {
switch (error_code) {
case ENOMEM:
return "out of memory";
case ENOSYS:
return "missing symbol";
case EINVAL:
return "invalid argument or main() missing";
default:
return std::format("code {}", error_code);
}
}
class ElfApp final : public App {
public:
struct Parameters {
CreateData createData = nullptr;
DestroyData destroyData = nullptr;
OnCreate onCreate = nullptr;
OnDestroy onDestroy = nullptr;
OnShow onShow = nullptr;
OnHide onHide = nullptr;
OnResult onResult = nullptr;
};
static void setParameters(const Parameters& parameters) {
staticParameters = parameters;
staticParametersSetCount++;
}
private:
static Parameters staticParameters;
static size_t staticParametersSetCount;
static std::shared_ptr<Lock> staticParametersLock;
const std::string appPath;
std::unique_ptr<uint8_t[]> elfFileData;
esp_elf_t elf {
.psegment = nullptr,
.svaddr = 0,
.ptext = nullptr,
.pdata = nullptr,
.sec = { },
.entry = nullptr
};
bool shouldCleanupElf = false; // Whether we have to clean up the above "elf" object
std::unique_ptr<Parameters> manifest;
void* data = nullptr;
std::string lastError = "";
bool startElf() {
const std::string elf_path = std::format("{}/elf/{}.elf", appPath, CONFIG_IDF_TARGET);
LOG_I(TAG, "Starting ELF %s", elf_path.c_str());
assert(elfFileData == nullptr);
size_t size = 0;
{
file::FileMutexGuard guard(elf_path);
elfFileData = file::readBinary(elf_path, size);
}
if (elfFileData == nullptr) {
return false;
}
if (esp_elf_init(&elf) != ESP_OK) {
lastError = "Failed to initialize";
LOG_E(TAG, "%s", lastError.c_str());
elfFileData = nullptr;
return false;
}
auto relocate_result = esp_elf_relocate(&elf, elfFileData.get());
if (relocate_result != 0) {
// Note: the result code maps to values from cstdlib's errno.h
lastError = getErrorCodeString(-relocate_result);
LOG_E(TAG, "Application failed to load: %s", lastError.c_str());
esp_elf_deinit(&elf);
elfFileData = nullptr;
return false;
}
int argc = 0;
char* argv[] = {};
if (esp_elf_request(&elf, 0, argc, argv) != ESP_OK) {
lastError = "Executable returned error code";
LOG_E(TAG, "%s", lastError.c_str());
esp_elf_deinit(&elf);
elfFileData = nullptr;
return false;
}
shouldCleanupElf = true;
return true;
}
void stopElf() {
LOG_I(TAG, "Cleaning up ELF");
if (shouldCleanupElf) {
esp_elf_deinit(&elf);
}
if (elfFileData != nullptr) {
elfFileData = nullptr;
}
}
public:
explicit ElfApp(std::string appPath) : appPath(std::move(appPath)) {}
void onCreate(AppContext& appContext) override {
// Because we use global variables, we have to ensure that we are not starting 2 apps in parallel
// We use a ScopedLock so we don't have to safeguard all branches
auto lock = staticParametersLock->asScopedLock();
lock.lock();
staticParametersSetCount = 0;
if (!startElf()) {
stop();
auto message = lastError.empty() ? "Application failed to start." : std::format("Application failed to start: {}", lastError);
alertdialog::start("Error", message);
return;
}
if (staticParametersSetCount == 0) {
stop();
alertdialog::start("Error", "Application failed to start: application failed to register itself");
return;
}
manifest = std::make_unique<Parameters>(staticParameters);
lock.unlock();
if (manifest->createData != nullptr) {
data = manifest->createData();
}
if (manifest->onCreate != nullptr) {
manifest->onCreate(&appContext, data);
}
}
void onDestroy(AppContext& appContext) override {
LOG_I(TAG, "Cleaning up app");
if (manifest != nullptr) {
if (manifest->onDestroy != nullptr) {
manifest->onDestroy(&appContext, data);
}
if (manifest->destroyData != nullptr && data != nullptr) {
manifest->destroyData(data);
}
this->manifest = nullptr;
}
stopElf();
}
void onShow(AppContext& appContext, lv_obj_t* parent) override {
if (manifest != nullptr && manifest->onShow != nullptr) {
manifest->onShow(&appContext, data, parent);
}
}
void onHide(AppContext& appContext) override {
if (manifest != nullptr && manifest->onHide != nullptr) {
manifest->onHide(&appContext, data);
}
}
void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> resultBundle) override {
if (manifest != nullptr && manifest->onResult != nullptr) {
manifest->onResult(&appContext, data, launchId, result, resultBundle.get());
}
}
};
ElfApp::Parameters ElfApp::staticParameters;
size_t ElfApp::staticParametersSetCount = 0;
std::shared_ptr<Lock> ElfApp::staticParametersLock = std::make_shared<Mutex>();
void setElfAppParameters(
CreateData createData,
DestroyData destroyData,
OnCreate onCreate,
OnDestroy onDestroy,
OnShow onShow,
OnHide onHide,
OnResult onResult
) {
ElfApp::setParameters({
.createData = createData,
.destroyData = destroyData,
.onCreate = onCreate,
.onDestroy = onDestroy,
.onShow = onShow,
.onHide = onHide,
.onResult = onResult
});
}
std::shared_ptr<App> createElfApp(const std::shared_ptr<AppManifest>& manifest) {
LOG_I(TAG, "createElfApp");
assert(manifest != nullptr);
assert(manifest->appLocation.isExternal());
return std::make_shared<ElfApp>(manifest->appLocation.getPath());
}
} // namespace
#endif // ESP_PLATFORM
+212 -162
View File
@@ -1,10 +1,15 @@
#include <Tactility/StringUtils.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/lvgl/Toolbar.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/icons/shared.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/drivers/uart_controller.h>
#include <tactility/log.h>
@@ -18,8 +23,12 @@ namespace tt::app::addgps {
constexpr auto* TAG = "AddGps";
class AddGpsApp final : public App {
extern const ::AppManifest manifest;
namespace {
struct Context {
uint32_t appInstanceId;
lv_obj_t* uartDropdown = nullptr;
lv_obj_t* modelDropdown = nullptr;
lv_obj_t* baudDropdown = nullptr;
@@ -30,168 +39,209 @@ class AddGpsApp final : public App {
// We only need to parse back to int when adding the new GPS entry
std::array<uint32_t, 6> baudRates = { 9600, 19200, 28800, 38400, 57600, 115200 };
const char* baudRatesDropdownValues = "9600\n19200\n28800\n38400\n57600\n115200";
static std::vector<std::string> getModelNames() {
std::vector<std::string> result;
for (int model = GpsModel::GPS_MODEL_UNKNOWN; model <= GpsModel::GPS_MODEL_UC6580; model++) {
result.emplace_back(gps_model_to_string(static_cast<GpsModel>(model)));
}
return result;
}
static void onAddGpsCallback(lv_event_t* event) {
auto* app = (AddGpsApp*)lv_event_get_user_data(event);
app->onAddGps();
}
void onAddGps() {
auto selected_baud_index = lv_dropdown_get_selected(baudDropdown);
GpsConfiguration new_configuration = {
.uart_name = { 0x00 },
.baud_rate = baudRates[selected_baud_index],
// Warning: This assumes that the enum is a regularly indexed one that starts at 0
.model = (GpsModel)lv_dropdown_get_selected(modelDropdown)
};
lv_dropdown_get_selected_str(uartDropdown, new_configuration.uart_name, sizeof(new_configuration.uart_name));
if (new_configuration.uart_name[0] == 0x00) {
alertdialog::start("Error", "You must select a bus/uart.");
return;
}
LOG_I(TAG, "Saving: uart=%s, model=%d, baud=%u", new_configuration.uart_name, (int)new_configuration.model, (unsigned)new_configuration.baud_rate);
if (gps_settings_add_configuration(&new_configuration) != ERROR_NONE) {
alertdialog::start("Error", "Failed to add configuration");
} else {
stop();
}
}
void updateUartDevices() {
devices.clear();
device_for_each_of_type(&UART_CONTROLLER_TYPE, &devices, [](auto* device, auto* context){
auto* vector_ptr = static_cast<std::vector<::Device*>*>(context);
vector_ptr->push_back(device);
return true;
});
}
std::string getUartDropdownNames() {
std::vector<std::string> names;
names.push_back("");
for (auto* device: devices) {
names.push_back(device->name);
}
return string::join(names, "\n");
}
public:
void onShow(AppContext& app, lv_obj_t* parent) final {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(main_wrapper, 0, 0);
lv_obj_set_style_border_width(main_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(main_wrapper);
// region Uart
auto* uart_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(uart_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_ver(uart_wrapper, 0, 0);
lv_obj_set_style_border_width(uart_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(uart_wrapper);
uartDropdown = lv_dropdown_create(uart_wrapper);
updateUartDevices();
auto uart_options = getUartDropdownNames();
lv_dropdown_set_options(uartDropdown, uart_options.c_str());
lv_obj_align(uartDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
lv_obj_set_width(uartDropdown, LV_PCT(50));
auto* uart_label = lv_label_create(uart_wrapper);
lv_obj_align(uart_label, LV_ALIGN_TOP_LEFT, 0, 10);
lv_label_set_text(uart_label, "Bus");
// region Model
auto* model_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(model_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_ver(model_wrapper, 0, 0);
lv_obj_set_style_border_width(model_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(model_wrapper);
modelDropdown = lv_dropdown_create(model_wrapper);
auto model_names = getModelNames();
auto model_options = string::join(model_names, "\n");
lv_dropdown_set_options(modelDropdown, model_options.c_str());
lv_obj_align(modelDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
lv_obj_set_width(modelDropdown, LV_PCT(50));
auto* model_label = lv_label_create(model_wrapper);
lv_obj_align(model_label, LV_ALIGN_TOP_LEFT, 0, 10);
lv_label_set_text(model_label, "Model");
// endregion
// region Baud
auto* baud_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(baud_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_ver(baud_wrapper, 0, 0);
lv_obj_set_style_border_width(baud_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(baud_wrapper);
baudDropdown = lv_dropdown_create(baud_wrapper);
lv_dropdown_set_options(baudDropdown, baudRatesDropdownValues);
lv_obj_align(baudDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
lv_obj_set_width(baudDropdown, LV_PCT(50));
auto* baud_rate_label = lv_label_create(baud_wrapper);
lv_obj_align(baud_rate_label, LV_ALIGN_TOP_LEFT, 0, 10);
lv_label_set_text(baud_rate_label, "Baud");
// endregion
// region Button
auto* button_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_ver(button_wrapper, 0, 0);
lv_obj_set_style_border_width(button_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(button_wrapper);
auto* add_button = lv_button_create(button_wrapper);
lv_obj_align(add_button, LV_ALIGN_TOP_MID, 0, 0);
auto* add_label = lv_label_create(add_button);
lv_label_set_text(add_label, "Add");
lv_obj_add_event_cb(add_button, onAddGpsCallback, LV_EVENT_SHORT_CLICKED, this);
// endregion
}
};
extern const AppManifest manifest = {
.appId = "AddGps",
.appName = "Add GPS",
.appIcon = LVGL_ICON_SHARED_NAVIGATION,
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<AddGpsApp>
};
void start() {
app::start(manifest.appId);
std::vector<std::string> getModelNames() {
std::vector<std::string> result;
for (int model = GpsModel::GPS_MODEL_UNKNOWN; model <= GpsModel::GPS_MODEL_UC6580; model++) {
result.emplace_back(gps_model_to_string(static_cast<GpsModel>(model)));
}
return result;
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onAddGpsPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto selected_baud_index = lv_dropdown_get_selected(ctx->baudDropdown);
GpsConfiguration new_configuration = {
.uart_name = { 0x00 },
.baud_rate = ctx->baudRates[selected_baud_index],
// Warning: This assumes that the enum is a regularly indexed one that starts at 0
.model = (GpsModel)lv_dropdown_get_selected(ctx->modelDropdown)
};
lv_dropdown_get_selected_str(ctx->uartDropdown, new_configuration.uart_name, sizeof(new_configuration.uart_name));
if (new_configuration.uart_name[0] == 0x00) {
alertdialog::start(ctx->appInstanceId, "Error", "You must select a bus/uart.");
return;
}
LOG_I(TAG, "Saving: uart=%s, model=%d, baud=%u", new_configuration.uart_name, (int)new_configuration.model, (unsigned)new_configuration.baud_rate);
if (gps_settings_add_configuration(&new_configuration) != ERROR_NONE) {
alertdialog::start(ctx->appInstanceId, "Error", "Failed to add configuration");
} else {
onBackPressed(event);
}
}
void updateUartDevices(Context* ctx) {
ctx->devices.clear();
device_for_each_of_type(&UART_CONTROLLER_TYPE, &ctx->devices, [](auto* device, auto* context) {
auto* vector_ptr = static_cast<std::vector<::Device*>*>(context);
vector_ptr->push_back(device);
return true;
});
}
std::string getUartDropdownNames(Context* ctx) {
std::vector<std::string> names;
names.push_back("");
for (auto* device: ctx->devices) {
names.push_back(device->name);
}
return string::join(names, "\n");
}
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 GPS");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(main_wrapper, 0, 0);
lv_obj_set_style_border_width(main_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(main_wrapper);
// region Uart
auto* uart_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(uart_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_ver(uart_wrapper, 0, 0);
lv_obj_set_style_border_width(uart_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(uart_wrapper);
ctx->uartDropdown = lv_dropdown_create(uart_wrapper);
updateUartDevices(ctx);
auto uart_options = getUartDropdownNames(ctx);
lv_dropdown_set_options(ctx->uartDropdown, uart_options.c_str());
lv_obj_align(ctx->uartDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
lv_obj_set_width(ctx->uartDropdown, LV_PCT(50));
auto* uart_label = lv_label_create(uart_wrapper);
lv_obj_align(uart_label, LV_ALIGN_TOP_LEFT, 0, 10);
lv_label_set_text(uart_label, "Bus");
// region Model
auto* model_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(model_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_ver(model_wrapper, 0, 0);
lv_obj_set_style_border_width(model_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(model_wrapper);
ctx->modelDropdown = lv_dropdown_create(model_wrapper);
auto model_names = getModelNames();
auto model_options = string::join(model_names, "\n");
lv_dropdown_set_options(ctx->modelDropdown, model_options.c_str());
lv_obj_align(ctx->modelDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
lv_obj_set_width(ctx->modelDropdown, LV_PCT(50));
auto* model_label = lv_label_create(model_wrapper);
lv_obj_align(model_label, LV_ALIGN_TOP_LEFT, 0, 10);
lv_label_set_text(model_label, "Model");
// endregion
// region Baud
auto* baud_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(baud_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_ver(baud_wrapper, 0, 0);
lv_obj_set_style_border_width(baud_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(baud_wrapper);
ctx->baudDropdown = lv_dropdown_create(baud_wrapper);
lv_dropdown_set_options(ctx->baudDropdown, ctx->baudRatesDropdownValues);
lv_obj_align(ctx->baudDropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
lv_obj_set_width(ctx->baudDropdown, LV_PCT(50));
auto* baud_rate_label = lv_label_create(baud_wrapper);
lv_obj_align(baud_rate_label, LV_ALIGN_TOP_LEFT, 0, 10);
lv_label_set_text(baud_rate_label, "Baud");
// endregion
// region Button
auto* button_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_ver(button_wrapper, 0, 0);
lv_obj_set_style_border_width(button_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(button_wrapper);
auto* add_button = lv_button_create(button_wrapper);
lv_obj_align(add_button, LV_ALIGN_TOP_MID, 0, 0);
auto* add_label = lv_label_create(add_button);
lv_label_set_text(add_label, "Add");
lv_obj_add_event_cb(add_button, onAddGpsPressed, LV_EVENT_SHORT_CLICKED, ctx);
// endregion
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
case APP_EVENT_RESULT:
app_manager_stop(event.result.launch_id);
break;
default:
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "AddGps",
.name = "Add GPS",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
+143 -123
View File
@@ -1,7 +1,10 @@
#include "Tactility/app/alertdialog/AlertDialog.h"
#include <Tactility/service/loader/Loader.h>
#include <Tactility/StringUtils.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
@@ -10,133 +13,150 @@
namespace tt::app::alertdialog {
#define PARAMETER_BUNDLE_KEY_TITLE "title"
#define PARAMETER_BUNDLE_KEY_MESSAGE "message"
#define PARAMETER_BUNDLE_KEY_BUTTON_LABELS "buttonLabels"
#define RESULT_BUNDLE_KEY_INDEX "index"
#define PARAMETER_ITEM_CONCATENATION_TOKEN ";;"
#define DEFAULT_TITLE ""
constexpr auto* TAG = "AlertDialog";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
LaunchId start(const std::string& title, const std::string& message, const std::vector<std::string>& buttonLabels) {
std::string items_joined = string::join(buttonLabels, PARAMETER_ITEM_CONCATENATION_TOKEN);
auto bundle = std::make_shared<Bundle>();
bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title);
bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message);
bundle->putString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, items_joined);
return app::start(manifest.appId, bundle);
}
namespace {
LaunchId start(const std::string& title, const std::string& message, const std::vector<const char*>& buttonLabels) {
std::string items_joined = string::join(buttonLabels, PARAMETER_ITEM_CONCATENATION_TOKEN);
auto bundle = std::make_shared<Bundle>();
bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title);
bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message);
bundle->putString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, items_joined);
return app::start(manifest.appId, bundle);
}
LaunchId start(const std::string& title, const std::string& message) {
auto bundle = std::make_shared<Bundle>();
bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title);
bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message);
bundle->putString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, "OK");
return app::start(manifest.appId, bundle);
}
int32_t getResultIndex(const Bundle& bundle) {
int32_t index = -1;
bundle.optInt32(RESULT_BUNDLE_KEY_INDEX, index);
return index;
}
static std::string getTitleParameter(std::shared_ptr<const Bundle> bundle) {
std::string result;
if (bundle->optString(PARAMETER_BUNDLE_KEY_TITLE, result)) {
return result;
} else {
return DEFAULT_TITLE;
}
}
class AlertDialogApp : public App {
static void onButtonClickedCallback(lv_event_t* e) {
auto app = std::static_pointer_cast<AlertDialogApp>(getCurrentApp());
assert(app != nullptr);
app->onButtonClicked(e);
}
void onButtonClicked(lv_event_t* e) {
auto index = reinterpret_cast<std::size_t>(lv_event_get_user_data(e));
LOG_I(TAG, "Selected item at index %d", (int)index);
auto bundle = std::make_unique<Bundle>();
bundle->putInt32(RESULT_BUNDLE_KEY_INDEX, (int32_t)index);
setResult(Result::Ok, std::move(bundle));
stop(manifest.appId);
}
static void createButton(lv_obj_t* parent, const std::string& text, size_t index) {
lv_obj_t* button = lv_button_create(parent);
lv_obj_t* button_label = lv_label_create(button);
lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(button_label, text.c_str());
lv_obj_add_event_cb(button, onButtonClickedCallback, LV_EVENT_SHORT_CLICKED, (void*)index);
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
auto parameters = app.getParameters();
check(parameters != nullptr, "Parameters missing");
std::string title = getTitleParameter(app.getParameters());
lv_obj_t* toolbar = lvgl_toolbar_create(parent, title.c_str());
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_t* message_label = lv_label_create(parent);
lv_obj_align(message_label, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_width(message_label, LV_PCT(80));
lv_obj_set_style_text_align(message_label, LV_TEXT_ALIGN_CENTER, 0);
std::string message;
if (parameters->optString(PARAMETER_BUNDLE_KEY_MESSAGE, message)) {
lv_label_set_text(message_label, message.c_str());
lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP);
}
lv_obj_t* button_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(button_wrapper, 0, 0);
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(button_wrapper, 0, 0);
lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4);
std::string items_concatenated;
if (parameters->optString(PARAMETER_BUNDLE_KEY_BUTTON_LABELS, items_concatenated)) {
std::vector<std::string> labels = string::split(items_concatenated, PARAMETER_ITEM_CONCATENATION_TOKEN);
size_t index = 0;
for (const auto& label: labels) {
createButton(button_wrapper, label, index++);
}
}
}
struct Context {
uint32_t appInstanceId;
// Set once in appMain() from its own argc/argv parameters, read by createWidgets() (which
// may run on a different task - the LVGL task, or another app's task via
// window_manager_remove()'s cross-thread rebuild-on-remove path). Safe to hold onto without a
// lock: the deep copy stays valid for exactly as long as appMain() is running, which is
// longer than createWidgets() ever needs it.
int argc = 0;
char** argv = nullptr;
// The eventual appMain() return value (= this dialog's APP_EVENT_RESULT result code) -
// written here by onButtonPressed() (LVGL thread) before it emits APP_EVENT_CLOSE, read by
// appMain() (this dialog's own thread) after waking from that event. No atomic/lock needed:
// the emit/await pair between the two already establishes happens-before ordering, same as
// every other cross-thread Context field write in this codebase's converted apps.
int32_t result = 1; // Cancelled - safety-net default if closed without pressing a button
};
extern const AppManifest manifest = {
.appId = "AlertDialog",
.appName = "Alert Dialog",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<AlertDialogApp>
struct ButtonContext {
Context* ctx;
int32_t index;
};
void onButtonDeleted(lv_event_t* e) {
delete static_cast<ButtonContext*>(lv_event_get_user_data(e));
}
void onButtonPressed(lv_event_t* e) {
auto* btnCtx = static_cast<ButtonContext*>(lv_event_get_user_data(e));
LOG_I(TAG, "Selected item at index %d", (int)btnCtx->index);
btnCtx->ctx->result = btnCtx->index;
// Async, non-blocking - just wakes this dialog's own thread. Must NOT call
// app_manager_stop() here: that bound-waits (thread_join) for the dialog's thread to
// finish, which needs the LVGL lock (window_manager_remove()) - but this callback is
// running ON the LVGL task, which would deadlock against itself. The caller reaps this
// instance via app_manager_stop() after it receives the APP_EVENT_RESULT instead.
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(btnCtx->ctx->appInstanceId, &event);
}
void createButton(Context* ctx, lv_obj_t* parent, const std::string& text, int32_t index) {
lv_obj_t* button = lv_button_create(parent);
lv_obj_t* button_label = lv_label_create(button);
lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(button_label, text.c_str());
auto* btnCtx = new ButtonContext { ctx, index };
lv_obj_add_event_cb(button, onButtonPressed, LV_EVENT_SHORT_CLICKED, btnCtx);
lv_obj_add_event_cb(button, onButtonDeleted, LV_EVENT_DELETE, btnCtx);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
// argv layout: [0]=title, [1]=message, [2..argc)=button labels.
int argc = ctx->argc;
char** argv = ctx->argv;
lv_obj_t* toolbar = lvgl_toolbar_create(parent, argv[0]);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_t* message_label = lv_label_create(parent);
lv_obj_align(message_label, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_width(message_label, LV_PCT(80));
lv_obj_set_style_text_align(message_label, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(message_label, argv[1]);
lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP);
lv_obj_t* button_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(button_wrapper, 0, 0);
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(button_wrapper, 0, 0);
lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4);
for (int32_t index = 0; index < argc - 2; index++) {
createButton(ctx, button_wrapper, argv[2 + index], index);
}
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx { appInstanceId };
ctx.argc = argc;
ctx.argv = argv;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
while (true) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
if (event.type == APP_EVENT_CLOSE) {
app_manager_finish(appInstanceId); // no-op: modal children never supersede anything
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return ctx.result;
}
} // namespace
namespace {
// Builds argv = [title, message, buttonLabels...] for app_manager_start_for_result().
std::vector<const char*> buildArgv(const std::string& title, const std::string& message, const std::vector<std::string>& buttonLabels) {
std::vector<const char*> argv { title.c_str(), message.c_str() };
for (const auto& label: buttonLabels) {
argv.push_back(label.c_str());
}
return argv;
}
} // namespace
uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message, const std::vector<std::string>& buttonLabels) {
auto argv = buildArgv(title, message, buttonLabels);
uint32_t instanceId = 0;
app_manager_start_for_result(manifest.id, callerAppInstanceId, static_cast<int>(argv.size()), argv.data(), &instanceId);
return instanceId;
}
uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message) {
return start(callerAppInstanceId, title, message, std::vector<std::string> { "OK" });
}
extern const ::AppManifest manifest = {
.id = "AlertDialog",
.name = "Alert Dialog",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
}
+143 -89
View File
@@ -1,114 +1,168 @@
#include <Tactility/app/App.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/StringUtils.h>
#include <Tactility/file/File.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <app/install.h>
#include <tactility/check.h>
#include <format>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <format>
#include <lvgl_window_manager/window_manager.h>
#include <Tactility/StringUtils.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/file/File.h>
#include <Tactility/lvgl/Style.h>
#include <tactility/log.h>
constexpr auto* TAG = "AppDetails";
namespace tt::app::appdetails {
extern const AppManifest manifest;
extern const ::AppManifest manifest;
void start(const std::string& appId) {
auto bundle = std::make_shared<Bundle>();
bundle->putString("appId", appId);
app::start(manifest.appId, bundle);
namespace {
struct Context {
uint32_t appInstanceId;
std::string targetAppId;
// findAppManifestById() returns the old-model registry's AppManifest type - AppDetails
// shows details for apps in that registry regardless of which system they run under.
AppManifest targetManifest = { };
uint32_t pendingUninstallDialogId = 0;
};
void onPressUninstall(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
std::vector<std::string> choices = { "Yes", "No" };
ctx->pendingUninstallDialogId = alertdialog::start(
ctx->appInstanceId,
"Confirmation",
std::format("Uninstall {}?", ctx->targetManifest.name),
choices
);
}
class AppDetailsApp : public App {
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
std::shared_ptr<AppManifest> manifest;
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);
static void onPressUninstall(lv_event_t* event) {
auto* self = static_cast<AppDetailsApp*>(lv_event_get_user_data(event));
std::vector<std::string> choices = {
"Yes",
"No"
};
alertdialog::start("Confirmation", std::format("Uninstall {}?", self->manifest->appName), choices);
}
auto title = std::format("{} details", ctx->targetManifest.name);
auto* toolbar = lvgl_toolbar_create(parent, title.c_str());
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
public:
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lvgl::obj_set_style_bg_invisible(wrapper);
void onCreate(AppContext& app) override {
const auto parameters = app.getParameters();
check(parameters != nullptr, "Parameters missing");
auto app_id = parameters->getString("appId");
manifest = findAppManifestById(app_id);
assert(manifest != nullptr);
}
auto identifier = std::format("Identifier: {}", ctx->targetManifest.id);
auto* identifier_label = lv_label_create(wrapper);
lv_label_set_text(identifier_label, identifier.c_str());
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
auto title = std::format("{} details", manifest->appName);
lvgl_toolbar_create(parent, title.c_str());
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lvgl::obj_set_style_bg_invisible(wrapper);
auto identifier = std::format("Identifier: {}", manifest->appId);
auto* identifier_label = lv_label_create(wrapper);
lv_label_set_text(identifier_label, identifier.c_str());
auto* location_label = lv_label_create(wrapper);
std::string location;
if (manifest->appLocation.isInternal()) {
location = "internal";
} else {
if (!string::getPathParent(manifest->appLocation.getPath(), location)) {
location = "external";
}
auto* location_label = lv_label_create(wrapper);
std::string location;
bool is_internal = ctx->targetManifest.location.type == APP_LOCATION_MEMORY;
bool is_external = ctx->targetManifest.location.type == APP_LOCATION_PATH;
if (is_internal) {
location = "internal";
} else if (is_external) {
if (!string::getPathParent(static_cast<const char*>(ctx->targetManifest.location.location), location)) {
location = "external";
}
std::string location_label_text = std::format("Location: {}", location);
lv_label_set_text(location_label, location_label_text.c_str());
} else {
LOG_E(TAG, "Unknown app location type %d", ctx->targetManifest.location.type);
return;
}
std::string location_label_text = std::format("Location: {}", location);
lv_label_set_text(location_label, location_label_text.c_str());
if (manifest->appLocation.isExternal()) {
auto* uninstall_button = lv_button_create(wrapper);
lv_obj_set_width(uninstall_button, LV_PCT(100));
lv_obj_add_event_cb(uninstall_button, onPressUninstall, LV_EVENT_SHORT_CLICKED, this);
auto* uninstall_label = lv_label_create(uninstall_button);
lv_obj_align(uninstall_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(uninstall_label, "Uninstall");
if (is_external) {
auto* uninstall_button = lv_button_create(wrapper);
lv_obj_set_width(uninstall_button, LV_PCT(100));
lv_obj_add_event_cb(uninstall_button, onPressUninstall, LV_EVENT_SHORT_CLICKED, ctx);
auto* uninstall_label = lv_label_create(uninstall_button);
lv_obj_align(uninstall_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(uninstall_label, "Uninstall");
}
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.targetAppId = (argc > 0) ? argv[0] : std::string();
ctx.targetManifest = *app_manager_find_manifest(ctx.targetAppId.c_str());
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
case APP_EVENT_RESULT:
if (event.result.launch_id == ctx.pendingUninstallDialogId) {
if (event.result.result == 0) { // 0 = Yes
app_uninstall(ctx.targetManifest.id);
app_manager_finish(appInstanceId);
shouldClose = true;
}
app_manager_stop(event.result.launch_id);
}
break;
default:
break;
}
}
void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) override {
if (result != Result::Ok || bundle == nullptr) {
return;
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
if (alertdialog::getResultIndex(*bundle) != 0) { // 0 = Yes
return;
}
uninstall(manifest->appId);
// Stop app
stop();
}
};
extern const AppManifest manifest = {
.appId = "AppDetails",
.appName = "App Details",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<AppDetailsApp>
};
return 0;
}
} // namespace
void start(const std::string& appId) {
const char* argv[] = { appId.c_str() };
uint32_t instanceId = 0;
app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId);
}
extern const ::AppManifest manifest = {
.id = "AppDetails",
.name = "App Details",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
+183 -155
View File
@@ -1,18 +1,23 @@
#include <Tactility/Paths.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/Mutex.h>
#include <Tactility/app/apphub/AppHub.h>
#include <Tactility/app/apphub/AppHubEntry.h>
#include <Tactility/app/apphubdetails/AppHubDetailsApp.h>
#include <Tactility/file/File.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/network/Http.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/service/wifi/Wifi.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/toolbar.h>
#include <algorithm>
#include <format>
@@ -21,166 +26,189 @@ namespace tt::app::apphub {
constexpr auto* TAG = "AppHub";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
class AppHubApp final : public App {
namespace {
struct Context {
uint32_t appInstanceId;
lv_obj_t* contentWrapper = nullptr;
lv_obj_t* refreshButton = nullptr;
std::string cachedAppsJsonFile = std::format("{}/app_hub.json", getTempPath());
std::unique_ptr<Thread> thread;
std::vector<AppHubEntry> entries;
Mutex mutex;
static std::shared_ptr<AppHubApp> findAppInstance() {
auto app_context = getCurrentAppContext();
if (app_context->getManifest().appId != manifest.appId) {
return nullptr;
}
return std::static_pointer_cast<AppHubApp>(app_context->getApp());
}
static void onAppPressed(lv_event_t* e) {
const auto* self = static_cast<AppHubApp*>(lv_event_get_user_data(e));
auto* widget = lv_event_get_target_obj(e);
const auto* user_data = lv_obj_get_user_data(widget);
const intptr_t index = reinterpret_cast<intptr_t>(user_data);
self->mutex.lock();
if (index < self->entries.size()) {
apphubdetails::start(self->entries[index]);
}
self->mutex.unlock();
}
static void onRefreshPressed(lv_event_t* e) {
auto* self = static_cast<AppHubApp*>(lv_event_get_user_data(e));
self->refresh();
}
void onRefreshSuccess() {
LOG_I(TAG, "Request success");
lvgl_lock();
showApps();
lvgl_unlock();
}
void onRefreshError(const char* error) {
LOG_E(TAG, "Request failed: %s", error);
lvgl_lock();
showRefreshFailedError("Cannot reach server");
lvgl_unlock();
}
static void createAppWidget(const std::shared_ptr<AppManifest>& manifest, lv_obj_t* list) {
lv_obj_t* btn = lv_list_add_button(list, nullptr, manifest->appName.c_str());
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, manifest.get());
}
void showRefreshFailedError(const char* message) {
lv_obj_clean(contentWrapper);
auto* label = lv_label_create(contentWrapper);
lv_label_set_text(label, message);
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
lv_obj_remove_flag(refreshButton, LV_OBJ_FLAG_HIDDEN);
}
void showNoInternet() {
showRefreshFailedError("No Internet Connection");
}
void showTimeNotSynced() {
showRefreshFailedError("Time is not synced yet.\nIt's required to establish a secure connection.");
}
void showApps() {
lv_obj_clean(contentWrapper);
mutex.lock();
if (parseJson(cachedAppsJsonFile, entries)) {
std::ranges::sort(entries, [](auto left, auto right) {
return left.appName < right.appName;
});
auto* list = lv_list_create(contentWrapper);
lv_obj_set_style_pad_all(list, 0, LV_STATE_DEFAULT);
lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT);
for (int i = 0; i < entries.size(); i++) {
auto& entry = entries[i];
LOG_I(TAG, "Adding %s", entry.appName.c_str());
const char* icon = findAppManifestById(entry.appId) != nullptr ? LV_SYMBOL_OK : nullptr;
auto* entry_button = lv_list_add_button(list, icon, entry.appName.c_str());
auto int_as_voidptr = reinterpret_cast<void*>(i);
lv_obj_set_user_data(entry_button, int_as_voidptr);
lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, this);
}
} else {
showRefreshFailedError("Failed to load content");
}
mutex.unlock();
}
void refresh() {
lv_obj_clean(contentWrapper);
auto* spinner = lvgl_spinner_create(contentWrapper);
lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 0);
lv_obj_add_flag(refreshButton, LV_OBJ_FLAG_HIDDEN);
if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) {
showNoInternet();
return;
}
if (file::isFile(cachedAppsJsonFile)) {
showApps();
}
network::http::download(
getAppsJsonUrl(),
CERTIFICATE_PATH,
cachedAppsJsonFile,
[] {
auto app = findAppInstance();
if (app != nullptr) {
app->onRefreshSuccess();
}
},
[](const char* error) {
auto app = findAppInstance();
if (app != nullptr) {
app->onRefreshError(error);
}
}
);
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
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, app);
refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, this);
lv_obj_add_flag(refreshButton, LV_OBJ_FLAG_HIDDEN);
contentWrapper = lv_obj_create(parent);
lv_obj_set_width(contentWrapper, LV_PCT(100));
lv_obj_set_flex_grow(contentWrapper, 1);
lv_obj_set_style_pad_all(contentWrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_ver(contentWrapper, 0, LV_STATE_DEFAULT);
refresh();
}
};
extern const AppManifest manifest = {
.appId = "AppHub",
.appName = "App Hub",
.appIcon = LVGL_ICON_SHARED_HUB,
.appCategory = Category::System,
.createApp = create<AppHubApp>,
void showApps(Context* ctx);
void refresh(Context* ctx);
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onAppPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
auto* widget = lv_event_get_target_obj(e);
const auto* user_data = lv_obj_get_user_data(widget);
const intptr_t index = reinterpret_cast<intptr_t>(user_data);
ctx->mutex.lock();
if (index < ctx->entries.size()) {
apphubdetails::start(ctx->entries[index]);
}
ctx->mutex.unlock();
}
void onRefreshPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
refresh(ctx);
}
void showRefreshFailedError(Context* ctx, const char* message) {
lv_obj_clean(ctx->contentWrapper);
auto* label = lv_label_create(ctx->contentWrapper);
lv_label_set_text(label, message);
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
lv_obj_remove_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN);
}
void showNoInternet(Context* ctx) {
showRefreshFailedError(ctx, "No Internet Connection");
}
void showApps(Context* ctx) {
lv_obj_clean(ctx->contentWrapper);
ctx->mutex.lock();
if (parseJson(ctx->cachedAppsJsonFile, ctx->entries)) {
std::ranges::sort(ctx->entries, [](auto left, auto right) {
return left.appName < right.appName;
});
auto* list = lv_list_create(ctx->contentWrapper);
lv_obj_set_style_pad_all(list, 0, LV_STATE_DEFAULT);
lv_obj_set_size(list, LV_PCT(100), LV_SIZE_CONTENT);
for (int i = 0; i < ctx->entries.size(); i++) {
auto& entry = ctx->entries[i];
LOG_I(TAG, "Adding %s", entry.appName.c_str());
const char* icon = app_manager_find_manifest(entry.appId.c_str()) != nullptr ? LV_SYMBOL_OK : nullptr;
auto* entry_button = lv_list_add_button(list, icon, entry.appName.c_str());
auto int_as_voidptr = reinterpret_cast<void*>(i);
lv_obj_set_user_data(entry_button, int_as_voidptr);
lv_obj_add_event_cb(entry_button, onAppPressed, LV_EVENT_SHORT_CLICKED, ctx);
}
} else {
showRefreshFailedError(ctx, "Failed to load content");
}
ctx->mutex.unlock();
}
void refresh(Context* ctx) {
lv_obj_clean(ctx->contentWrapper);
auto* spinner = lvgl_spinner_create(ctx->contentWrapper);
lv_obj_align(spinner, LV_ALIGN_CENTER, 0, 0);
lv_obj_add_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN);
if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) {
showNoInternet(ctx);
return;
}
if (file::isFile(ctx->cachedAppsJsonFile)) {
showApps(ctx);
}
// These callbacks run on a background network thread and reach back into this app's
// widgets via the captured ctx pointer - same convention as AppHubDetailsApp.cpp's
// download callback for the sibling "install/update" flow.
network::http::download(
getAppsJsonUrl(),
CERTIFICATE_PATH,
ctx->cachedAppsJsonFile,
[ctx] {
LOG_I(TAG, "Request success");
lvgl_lock();
showApps(ctx);
lvgl_unlock();
},
[ctx](const char* error) {
LOG_E(TAG, "Request failed: %s", error);
lvgl_lock();
showRefreshFailedError(ctx, "Cannot reach server");
lvgl_unlock();
}
);
}
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, "App Hub");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
ctx->refreshButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_REFRESH, onRefreshPressed, ctx);
lv_obj_add_flag(ctx->refreshButton, LV_OBJ_FLAG_HIDDEN);
ctx->contentWrapper = lv_obj_create(parent);
lv_obj_set_width(ctx->contentWrapper, LV_PCT(100));
lv_obj_set_flex_grow(ctx->contentWrapper, 1);
lv_obj_set_style_pad_all(ctx->contentWrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_ver(ctx->contentWrapper, 0, LV_STATE_DEFAULT);
refresh(ctx);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx;
ctx.appInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "AppHub",
.name = "App Hub",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace
@@ -1,255 +1,317 @@
#include <Tactility/Paths.h>
#include "../../../../Modules/app-module/private/app/private/app_ledger.h"
#include "app/metadata.h"
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/StringUtils.h>
#include <Tactility/app/AppRegistration.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/app/apphub/AppHub.h>
#include <Tactility/app/apphub/AppHubEntry.h>
#include <Tactility/file/File.h>
#include <Tactility/network/Http.h>
#include <Tactility/service/loader/Loader.h>
#include <app/event.h>
#include <app/install.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/log.h>
#include <atomic>
#include <cstdlib>
#include <format>
namespace tt::app::apphubdetails {
constexpr auto* TAG = "AppHubDetails";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
static std::shared_ptr<Bundle> toBundle(const apphub::AppHubEntry& entry) {
auto bundle = std::make_shared<Bundle>();
bundle->putString("appId", entry.appId);
bundle->putString("appVersionName", entry.appVersionName);
bundle->putInt32("appVersionCode", entry.appVersionCode);
bundle->putString("appName", entry.appName);
bundle->putString("appDescription", entry.appDescription);
bundle->putString("targetSdk", entry.targetSdk);
bundle->putString("file", entry.file);
bundle->putString("targetPlatforms", string::join(entry.targetPlatforms, ","));
return bundle;
}
namespace {
static bool fromBundle(const Bundle& bundle, apphub::AppHubEntry& entry) {
std::string target_platforms_string;
auto result = bundle.optString("appId", entry.appId) &&
bundle.optString("appVersionName", entry.appVersionName) &&
bundle.optInt32("appVersionCode", entry.appVersionCode) &&
bundle.optString("appName", entry.appName) &&
bundle.optString("appDescription", entry.appDescription) &&
bundle.optString("targetSdk", entry.targetSdk) &&
bundle.optString("file", entry.file) &&
bundle.optString("targetPlatforms", target_platforms_string);
entry.targetPlatforms = string::split(target_platforms_string, ",");
return result;
}
class AppHubDetailsApp final : public App {
static constexpr auto* CONFIRM_TEXT = "Confirm";
static constexpr auto* CANCEL_TEXT = "Cancel";
static constexpr auto CONFIRMATION_BUTTON_INDEX = 0;
const std::vector<const char*> CONFIRM_CANCEL_LABELS = { CONFIRM_TEXT, CANCEL_TEXT };
constexpr auto* CONFIRM_TEXT = "Confirm";
constexpr auto* CANCEL_TEXT = "Cancel";
constexpr int32_t CONFIRMATION_BUTTON_INDEX = 0;
struct Context {
uint32_t appInstanceId;
apphub::AppHubEntry entry;
std::shared_ptr<AppManifest> entryManifest;
lv_obj_t* toolbar = nullptr;
lv_obj_t* spinner = nullptr;
lv_obj_t* updateButton = nullptr;
lv_obj_t* updateLabel = nullptr;
LaunchId installLaunchId = -1;
LaunchId uninstallLaunchId = -1;
LaunchId updateLaunchId = -1;
LaunchId showConfirmDialog(const char* action) {
const auto message = std::format("{} {}?", action, entry.appName);
return alertdialog::start(CONFIRM_TEXT, message, CONFIRM_CANCEL_LABELS);
}
static void onInstallPressed(lv_event_t* e) {
auto* self = static_cast<AppHubDetailsApp*>(lv_event_get_user_data(e));
self->installLaunchId = self->showConfirmDialog("Install");
}
static void onUninstallPressed(lv_event_t* e) {
auto* self = static_cast<AppHubDetailsApp*>(lv_event_get_user_data(e));
self->uninstallLaunchId = self->showConfirmDialog("Uninstall");
}
static void onUpdatePressed(lv_event_t* e) {
auto* self = static_cast<AppHubDetailsApp*>(lv_event_get_user_data(e));
self->updateLaunchId = self->showConfirmDialog("Update");
}
void uninstallApp() {
LOG_I(TAG, "Uninstall");
lvgl_lock();
lv_obj_remove_flag(spinner, LV_OBJ_FLAG_HIDDEN);
lvgl_unlock();
uninstall(entry.appId);
lvgl_lock();
updateViews();
lvgl_unlock();
}
void doInstall() {
auto url = apphub::getDownloadUrl(entry.file);
auto file_name = file::getLastPathSegment(entry.file);
auto temp_file_path = std::format("{}/{}", getTempPath(), file_name);
network::http::download(
url,
apphub::CERTIFICATE_PATH,
temp_file_path,
[this, temp_file_path] {
install(temp_file_path);
if (!file::deleteFile(temp_file_path)) {
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
} else {
LOG_I(TAG, "Deleted temporary file %s", temp_file_path.c_str());
}
lvgl_lock();
updateViews();
lvgl_unlock();
},
[temp_file_path](const char* errorMessage) {
LOG_E(TAG, "Download failed: %s", errorMessage);
alertdialog::start("Error", "Failed to install app");
if (file::isFile(temp_file_path) && !file::deleteFile(temp_file_path.c_str())) {
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
}
}
);
}
void installApp() {
LOG_I(TAG, "Install");
lvgl_lock();
lv_obj_remove_flag(spinner, LV_OBJ_FLAG_HIDDEN);
lvgl_unlock();
doInstall();
}
void updateApp() {
LOG_I(TAG, "Update");
lvgl_lock();
lv_obj_remove_flag(spinner, LV_OBJ_FLAG_HIDDEN);
lvgl_unlock();
LOG_I(TAG, "Removing previous version");
uninstall(entry.appId);
LOG_I(TAG, "Installing new version");
doInstall();
}
void updateViews() {
lvgl_toolbar_clear_actions(toolbar);
const auto manifest = findAppManifestById(entry.appId);
spinner = lvgl_toolbar_add_spinner_action(toolbar);
lv_obj_add_flag(spinner, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(updateLabel, LV_OBJ_FLAG_HIDDEN);
if (manifest != nullptr) {
if (manifest->appVersionCode < entry.appVersionCode) {
updateButton = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DOWNLOAD, onUpdatePressed, this);
lv_obj_remove_flag(updateLabel, LV_OBJ_FLAG_HIDDEN);
}
lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_TRASH, onUninstallPressed, this);
} else {
lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DOWNLOAD, onInstallPressed, this);
}
}
public:
void onCreate(AppContext& appContext) override {
auto parameters = appContext.getParameters();
if (parameters == nullptr) {
LOG_E(TAG, "No parameters");
stop();
return;
}
if (!fromBundle(*parameters.get(), entry)) {
LOG_E(TAG, "Invalid parameters");
stop();
}
}
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
toolbar = lvgl_toolbar_create(parent, entry.appName.c_str());
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
updateLabel = lv_label_create(wrapper);
lv_label_set_text(updateLabel, "Update available!");
lv_obj_set_style_text_color(updateLabel, lv_color_make(0xff, 0xff, 00), LV_STATE_DEFAULT);
auto* description_label = lv_label_create(wrapper);
lv_obj_set_width(description_label, LV_PCT(100));
lv_label_set_long_mode(description_label, LV_LABEL_LONG_MODE_WRAP);
if (!entry.appDescription.empty()) {
std::string description = entry.appDescription;
for (size_t pos = 0; (pos = description.find("\\n", pos)) != std::string::npos;) {
description.replace(pos, 2, "\n");
}
lv_label_set_text(description_label, description.c_str());
} else {
lv_label_set_text(description_label, "This app has no description yet.");
}
auto* version_label = lv_label_create(wrapper);
lv_label_set_text_fmt(version_label, "Version %s", entry.appVersionName.c_str());
updateViews();
}
void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> resultData) override {
if (result != Result::Ok) {
return;
}
if (alertdialog::getResultIndex(*resultData.get()) != CONFIRMATION_BUTTON_INDEX) {
return;
}
if (launchId == installLaunchId) {
installApp();
} else if (launchId == uninstallLaunchId) {
uninstallApp();
} else if (launchId == updateLaunchId) {
updateApp();
}
}
// Set from the LVGL task (button press), read from this app's own thread (event loop) -
// both directions cross threads, hence atomic.
std::atomic<uint32_t> installDialogId = 0;
std::atomic<uint32_t> uninstallDialogId = 0;
std::atomic<uint32_t> updateDialogId = 0;
};
void start(const apphub::AppHubEntry& entry) {
const auto bundle = toBundle(entry);
app::start(manifest.appId, bundle);
void updateViews(Context* ctx);
uint32_t showConfirmDialog(Context* ctx, const char* action) {
const auto message = std::format("{} {}?", action, ctx->entry.appName);
return alertdialog::start(ctx->appInstanceId, CONFIRM_TEXT, message, std::vector<std::string> { CONFIRM_TEXT, CANCEL_TEXT });
}
extern const AppManifest manifest = {
.appId = "AppHubDetails",
.appName = "App Details",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<AppHubDetailsApp>,
void onBackPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onInstallPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
ctx->installDialogId = showConfirmDialog(ctx, "Install");
}
void onUninstallPressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
ctx->uninstallDialogId = showConfirmDialog(ctx, "Uninstall");
}
void onUpdatePressed(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
ctx->updateDialogId = showConfirmDialog(ctx, "Update");
}
void uninstallApp(Context* ctx) {
LOG_I(TAG, "Uninstall");
lvgl_lock();
lv_obj_remove_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN);
lvgl_unlock();
app_uninstall(ctx->entry.appId.c_str());
lvgl_lock();
updateViews(ctx);
lvgl_unlock();
}
void doInstall(Context* ctx) {
auto url = apphub::getDownloadUrl(ctx->entry.file);
auto file_name = file::getLastPathSegment(ctx->entry.file);
auto temp_file_path = std::format("{}/{}", getTempPath(), file_name);
network::http::download(
url,
apphub::CERTIFICATE_PATH,
temp_file_path,
[ctx, temp_file_path] {
app_install(temp_file_path.c_str());
if (!file::deleteFile(temp_file_path)) {
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
} else {
LOG_I(TAG, "Deleted temporary file %s", temp_file_path.c_str());
}
lvgl_lock();
updateViews(ctx);
lvgl_unlock();
},
[ctx, temp_file_path](const char* errorMessage) {
LOG_E(TAG, "Download failed: %s", errorMessage);
alertdialog::start(ctx->appInstanceId, "Error", "Failed to install app");
if (file::isFile(temp_file_path) && !file::deleteFile(temp_file_path.c_str())) {
LOG_W(TAG, "Failed to remove %s", temp_file_path.c_str());
}
}
);
}
void installApp(Context* ctx) {
LOG_I(TAG, "Install");
lvgl_lock();
lv_obj_remove_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN);
lvgl_unlock();
doInstall(ctx);
}
void updateApp(Context* ctx) {
LOG_I(TAG, "Update");
lvgl_lock();
lv_obj_remove_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN);
lvgl_unlock();
LOG_I(TAG, "Removing previous version");
app_uninstall(ctx->entry.appId.c_str());
LOG_I(TAG, "Installing new version");
doInstall(ctx);
}
void updateViews(Context* ctx) {
lvgl_toolbar_clear_actions(ctx->toolbar);
auto app_id = ctx->entry.appId.c_str();
const auto manifest = app_manager_find_manifest(app_id);
ctx->spinner = lvgl_toolbar_add_spinner_action(ctx->toolbar);
lv_obj_add_flag(ctx->spinner, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(ctx->updateLabel, LV_OBJ_FLAG_HIDDEN);
char install_path[128];
if (app_get_install_path(app_id, install_path, sizeof(install_path)) != ERROR_NONE) {
LOG_E(TAG, "Install path not found for %s", app_id);
return;
}
std::string metadata_path = std::string(install_path) + "/manifest.properties";
AppMetadata metadata;
if (app_metadata_parse(metadata_path.c_str(), &metadata) != ERROR_NONE) {
LOG_E(TAG, "Failed to parse metadata at %s", metadata_path.c_str());
return;
}
if (manifest != nullptr) {
if (metadata.app_version_code < ctx->entry.appVersionCode) {
ctx->updateButton = lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_DOWNLOAD, onUpdatePressed, ctx);
lv_obj_remove_flag(ctx->updateLabel, LV_OBJ_FLAG_HIDDEN);
}
lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_TRASH, onUninstallPressed, ctx);
} else {
lvgl_toolbar_add_image_button_action(ctx->toolbar, LV_SYMBOL_DOWNLOAD, onInstallPressed, ctx);
}
}
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);
ctx->toolbar = lvgl_toolbar_create(parent, ctx->entry.appName.c_str());
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(ctx->toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
ctx->updateLabel = lv_label_create(wrapper);
lv_label_set_text(ctx->updateLabel, "Update available!");
lv_obj_set_style_text_color(ctx->updateLabel, lv_color_make(0xff, 0xff, 00), LV_STATE_DEFAULT);
auto* description_label = lv_label_create(wrapper);
lv_obj_set_width(description_label, LV_PCT(100));
lv_label_set_long_mode(description_label, LV_LABEL_LONG_MODE_WRAP);
if (!ctx->entry.appDescription.empty()) {
std::string description = ctx->entry.appDescription;
for (size_t pos = 0; (pos = description.find("\\n", pos)) != std::string::npos;) {
description.replace(pos, 2, "\n");
}
lv_label_set_text(description_label, description.c_str());
} else {
lv_label_set_text(description_label, "This app has no description yet.");
}
auto* version_label = lv_label_create(wrapper);
lv_label_set_text_fmt(version_label, "Version %s", ctx->entry.appVersionName.c_str());
updateViews(ctx);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
// argv layout: [0]=appId, [1]=appVersionName, [2]=appVersionCode, [3]=appName,
// [4]=appDescription, [5]=targetSdk, [6]=file, [7..argc)=targetPlatforms.
Context ctx {};
ctx.appInstanceId = appInstanceId;
if (argc >= 7) {
ctx.entry.appId = argv[0];
ctx.entry.appVersionName = argv[1];
ctx.entry.appVersionCode = static_cast<int32_t>(strtol(argv[2], nullptr, 10));
ctx.entry.appName = argv[3];
ctx.entry.appDescription = argv[4];
ctx.entry.targetSdk = argv[5];
ctx.entry.file = argv[6];
for (int i = 7; i < argc; i++) {
ctx.entry.targetPlatforms.emplace_back(argv[i]);
}
}
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
case APP_EVENT_RESULT: {
bool confirmed = event.result.result == CONFIRMATION_BUTTON_INDEX;
if (event.result.launch_id == ctx.installDialogId && confirmed) {
installApp(&ctx);
} else if (event.result.launch_id == ctx.uninstallDialogId && confirmed) {
uninstallApp(&ctx);
} else if (event.result.launch_id == ctx.updateDialogId && confirmed) {
updateApp(&ctx);
}
app_manager_stop(event.result.launch_id);
break;
}
default:
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
void start(const apphub::AppHubEntry& entry) {
// Fire-and-forget (parent_instance_id 0): AppHub's own multi-app browsing list isn't
// waiting on a result. targetPlatforms is variable-length, so it goes last in argv.
std::string versionCode = std::to_string(entry.appVersionCode);
std::vector<const char*> argv {
entry.appId.c_str(),
entry.appVersionName.c_str(),
versionCode.c_str(),
entry.appName.c_str(),
entry.appDescription.c_str(),
entry.targetSdk.c_str(),
entry.file.c_str(),
};
for (const auto& platform: entry.targetPlatforms) {
argv.push_back(platform.c_str());
}
uint32_t instanceId = 0;
app_manager_start_for_result(manifest.id, /*parent_instance_id=*/0, static_cast<int>(argv.size()), argv.data(), &instanceId);
}
extern const ::AppManifest manifest = {
.id = "AppHubDetails",
.name = "App Details",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
+93 -40
View File
@@ -1,63 +1,116 @@
#include <Tactility/app/AppRegistration.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/lvgl/Toolbar.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl.h>
#include <algorithm>
#include <cstring>
#include <vector>
#include <lvgl/icons/shared.h>
#include <lvgl/fonts.h>
#include <lvgl/widgets/toolbar.h>
namespace tt::app::applist {
class AppListApp final : public App {
namespace {
static void onAppPressed(lv_event_t* e) {
const auto* manifest = static_cast<const AppManifest*>(lv_event_get_user_data(e));
start(manifest->appId);
}
uint32_t appListInstanceId = 0;
static void createAppWidget(const std::shared_ptr<AppManifest>& manifest, lv_obj_t* list) {
const void* icon = !manifest->appIcon.empty() ? manifest->appIcon.c_str() : LVGL_ICON_SHARED_TOOLBAR;
lv_obj_t* btn = lv_list_add_button(list, icon, manifest->appName.c_str());
lv_obj_t* image = lv_obj_get_child(btn, 0);
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN);
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, manifest.get());
}
void onAppPressed(lv_event_t* e) {
// Fire-and-forget top-level navigation, same as Launcher's own app-launch buttons.
const auto* manifest = static_cast<const ::AppManifest*>(lv_event_get_user_data(e));
uint32_t instanceId = 0;
app_manager_start(manifest->id, &instanceId);
}
public:
void onBackPressed(lv_event_t*) {
// The global toolbar nav callback (ToolbarConfig.nav_action_callback, set once in
// Tactility.cpp) only knows how to stop old-model apps, so this new-model app overrides
// its own toolbar's nav action to close itself instead. Async, non-blocking - must NOT
// call app_manager_stop() directly here: that bound-waits (thread_join) for this app's
// own thread to finish, which needs the LVGL lock (window_manager_remove()) - but this
// callback runs ON the LVGL task, which would deadlock against itself.
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(appListInstanceId, &event);
}
void onShow(AppContext& app, lv_obj_t* parent) override {
auto* toolbar = lvgl::toolbar_create(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
void createAppWidget(const ::AppManifest* manifest, lv_obj_t* list) {
// The new AppManifest has no per-app icon - use a shared generic one for every entry,
// same fallback the old model used for apps that didn't provide one.
lv_obj_t* btn = lv_list_add_button(list, LVGL_ICON_SHARED_TOOLBAR, manifest->name);
lv_obj_t* image = lv_obj_get_child(btn, 0);
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN);
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, const_cast<::AppManifest*>(manifest));
}
lv_obj_t* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
void collectManifest(const ::AppManifest* manifest, void* context) {
auto* manifests = static_cast<std::vector<const ::AppManifest*>*>(context);
manifests->push_back(manifest);
}
auto toolbar_height = lv_obj_get_height(toolbar);
auto parent_content_height = lv_obj_get_content_height(parent);
lv_obj_set_height(list, parent_content_height - toolbar_height);
void createWidgets(lv_obj_t* parent, void*) {
auto* toolbar = lvgl_toolbar_create(parent, "Apps");
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, nullptr);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
auto manifests = getAppManifests();
std::ranges::sort(manifests, SortAppManifestByName);
lv_obj_t* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
for (const auto& manifest: manifests) {
bool is_valid_category = (manifest->appCategory == Category::User) || (manifest->appCategory == Category::System);
bool is_visible = (manifest->appFlags & AppManifest::Flags::Hidden) == 0u;
if (is_valid_category && is_visible) {
createAppWidget(manifest, list);
}
auto toolbar_height = lv_obj_get_height(toolbar);
auto parent_content_height = lv_obj_get_content_height(parent);
lv_obj_set_height(list, parent_content_height - toolbar_height);
std::vector<const ::AppManifest*> manifests;
app_manager_for_each_manifest(collectManifest, &manifests);
std::ranges::sort(manifests, [](const ::AppManifest* a, const ::AppManifest* b) {
return strcmp(a->name, b->name) < 0;
});
for (const auto* manifest: manifests) {
bool is_valid_category = (manifest->category == APP_CATEGORY_USER) || (manifest->category == APP_CATEGORY_SYSTEM);
if (is_valid_category && (manifest->flags & APP_MANIFEST_FLAG_HIDDEN) == 0) {
createAppWidget(manifest, list);
}
}
};
}
extern const AppManifest manifest = {
.appId = "AppList",
.appName = "Apps",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<AppListApp>,
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
appListInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr);
while (true) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
if (event.type == APP_EVENT_CLOSE) {
app_manager_finish(appInstanceId);
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "AppList",
.name = "Apps",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
+105 -45
View File
@@ -1,70 +1,130 @@
#include <lvgl/icons/shared.h>
#include <lvgl/fonts.h>
#include <Tactility/app/AppRegistration.h>
#include <Tactility/app/appdetails/AppDetails.h>
#include <Tactility/service/loader/Loader.h>
#include <lvgl/widgets/toolbar.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/widgets/toolbar.h>
#include <lvgl.h>
#include <algorithm>
#include <cstring>
#include <vector>
namespace tt::app::appsettings {
class AppSettingsApp final : public App {
extern const ::AppManifest manifest;
static void onAppPressed(lv_event_t* e) {
const auto* manifest = static_cast<const AppManifest*>(lv_event_get_user_data(e));
appdetails::start(manifest->appId);
}
namespace {
static void createAppWidget(const std::shared_ptr<AppManifest>& manifest, lv_obj_t* list) {
const void* icon = !manifest->appIcon.empty() ? manifest->appIcon.c_str() : LVGL_ICON_SHARED_TOOLBAR;
lv_obj_t* btn = lv_list_add_button(list, icon, manifest->appName.c_str());
lv_obj_t* image = lv_obj_get_child(btn, 0);
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN);
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, manifest.get());
}
// Set by appMain() right before window_manager_create(), read by onBackPressed().
uint32_t appSettingsInstanceId = 0;
public:
void onAppPressed(lv_event_t* e) {
const auto* target_manifest = static_cast<const ::AppManifest*>(lv_event_get_user_data(e));
appdetails::start(target_manifest->id);
}
void onShow(AppContext& app, lv_obj_t* parent) override {
auto* toolbar = lvgl_toolbar_create(parent, "Installed Apps");
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
void onBackPressed(lv_event_t*) {
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(appSettingsInstanceId, &event);
}
lv_obj_t* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
void createAppWidget(const ::AppManifest* target_manifest, lv_obj_t* list) {
// The new AppManifest has no per-app icon - use a shared generic one for every entry, same
// fallback AppList.cpp uses.
lv_obj_t* btn = lv_list_add_button(list, LVGL_ICON_SHARED_TOOLBAR, target_manifest->name);
lv_obj_t* image = lv_obj_get_child(btn, 0);
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN);
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, const_cast<::AppManifest*>(target_manifest));
}
auto toolbar_height = lv_obj_get_height(toolbar);
auto parent_content_height = lv_obj_get_content_height(parent);
lv_obj_set_height(list, parent_content_height - toolbar_height);
void collectManifest(const ::AppManifest* manifest, void* context) {
auto* manifests = static_cast<std::vector<const ::AppManifest*>*>(context);
manifests->push_back(manifest);
}
auto manifests = getAppManifests();
std::ranges::sort(manifests, SortAppManifestByName);
void createWidgets(lv_obj_t* parent, void*) {
auto* toolbar = lvgl_toolbar_create(parent, "Installed Apps");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, nullptr);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
size_t app_count = 0;
for (const auto& manifest: manifests) {
if (manifest->appLocation.isExternal()) {
app_count++;
createAppWidget(manifest, list);
}
}
lv_obj_t* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_align_to(list, toolbar, LV_ALIGN_OUT_BOTTOM_MID, 0, 0);
if (app_count == 0) {
auto* no_apps_label = lv_label_create(parent);
lv_label_set_text(no_apps_label, "No apps installed");
lv_obj_align(no_apps_label, LV_ALIGN_CENTER, 0, 0);
auto toolbar_height = lv_obj_get_height(toolbar);
auto parent_content_height = lv_obj_get_content_height(parent);
lv_obj_set_height(list, parent_content_height - toolbar_height);
std::vector<const ::AppManifest*> manifests;
app_manager_for_each_manifest(collectManifest, &manifests);
std::ranges::sort(manifests, [](const ::AppManifest* a, const ::AppManifest* b) {
return strcmp(a->name, b->name) < 0;
});
size_t app_count = 0;
for (const auto* target_manifest: manifests) {
if (target_manifest->location.type == APP_LOCATION_PATH) {
app_count++;
createAppWidget(target_manifest, list);
}
}
};
extern const AppManifest manifest = {
.appId = "AppSettings",
.appName = "Apps",
.appIcon = LVGL_ICON_SHARED_APPS,
.appCategory = Category::Settings,
.createApp = create<AppSettingsApp>,
if (app_count == 0) {
auto* no_apps_label = lv_label_create(parent);
lv_label_set_text(no_apps_label, "No apps installed");
lv_obj_align(no_apps_label, LV_ALIGN_CENTER, 0, 0);
}
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
appSettingsInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "AppSettings",
.name = "Apps",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace
+163 -107
View File
@@ -1,130 +1,186 @@
#ifdef ESP_PLATFORM
#include <Tactility/Tactility.h>
#include <Tactility/app/App.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/webserver/WebServerService.h>
#include <Tactility/settings/WebServerSettings.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/log.h>
namespace tt::app::apwebserver {
constexpr auto* TAG = "ApWebServerApp";
class ApWebServerApp final : public App {
extern const ::AppManifest manifest;
namespace {
struct Context {
uint32_t appInstanceId;
lv_obj_t* labelSsidValue = nullptr;
lv_obj_t* labelPasswordValue = nullptr;
lv_obj_t* labelIpValue = nullptr;
bool webServerEnabledChanged = false;
settings::webserver::WebServerSettings wsSettings;
public:
void onCreate(AppContext& app) override {
wsSettings = settings::webserver::loadOrGetDefault();
}
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lvgl::toolbar_create(parent, app);
lv_obj_t* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN);
lv_obj_set_style_pad_row(wrapper, 4, LV_PART_MAIN);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_t* labelSsid = lv_label_create(wrapper);
lv_label_set_text(labelSsid, "SSID:");
lv_obj_set_style_text_color(labelSsid, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
labelSsidValue = lv_label_create(wrapper);
lv_obj_set_style_text_align(labelSsidValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_set_width(labelSsidValue, LV_PCT(100));
lv_label_set_long_mode(labelSsidValue, LV_LABEL_LONG_SCROLL);
lv_obj_set_style_margin_hor(labelSsidValue, 2, LV_PART_MAIN);
lv_obj_t* labelPassword = lv_label_create(wrapper);
lv_label_set_text(labelPassword, "Pass:");
lv_obj_set_style_text_color(labelPassword, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
labelPasswordValue = lv_label_create(wrapper);
lv_obj_set_style_text_align(labelPasswordValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_set_width(labelPasswordValue, LV_PCT(100));
lv_label_set_long_mode(labelPasswordValue, LV_LABEL_LONG_SCROLL);
lv_obj_set_style_margin_hor(labelPasswordValue, 2, LV_PART_MAIN);
lv_obj_t* labelIp = lv_label_create(wrapper);
lv_label_set_text(labelIp, "IP:");
lv_obj_set_style_text_color(labelIp, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
labelIpValue = lv_label_create(wrapper);
lv_obj_set_style_text_align(labelIpValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_set_width(labelIpValue, LV_PCT(100));
lv_label_set_long_mode(labelIpValue, LV_LABEL_LONG_SCROLL);
lv_obj_set_style_margin_hor(labelIpValue, 2, LV_PART_MAIN);
// Start AP Mode and WebServer
settings::webserver::WebServerSettings apSettings = wsSettings;
apSettings.wifiMode = settings::webserver::WiFiMode::AccessPoint;
apSettings.webServerEnabled = true;
if (apSettings.apSsid.empty()) {
apSettings.apSsid = settings::webserver::generateDefaultApSsid();
}
// Generate password if it's an open network or if password is empty
if (apSettings.apOpenNetwork || apSettings.apPassword.empty()) {
apSettings.apPassword = settings::webserver::generateRandomCredential(12);
apSettings.apOpenNetwork = false;
}
lv_label_set_text(labelSsidValue, apSettings.apSsid.c_str());
lv_label_set_text(labelPasswordValue, apSettings.apPassword.c_str());
lv_label_set_text(labelIpValue, "192.168.4.1");
// Apply settings and start services
getMainDispatcher().dispatch([apSettings] {
if (!settings::webserver::save(apSettings)) {
LOG_E(TAG, "Failed to save AP settings");
return;
}
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
service::webserver::setWebServerEnabled(true);
});
webServerEnabledChanged = true;
}
void onHide(AppContext& app) override {
const auto copy = wsSettings;
const bool webServerChanged = webServerEnabledChanged;
getMainDispatcher().dispatch([copy, webServerChanged] {
if (!settings::webserver::save(copy)) {
LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot");
}
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
if (webServerChanged) {
LOG_I(TAG, "WebServer %s", copy.webServerEnabled ? "enabling..." : "disabling...");
service::webserver::setWebServerEnabled(copy.webServerEnabled);
}
});
}
};
extern const AppManifest manifest = {
.appId = "ApWebServer",
.appName = "AP Web Server",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<ApWebServerApp>
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
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_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
auto* toolbar = lvgl_toolbar_create(parent, "AP Web Server");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
lv_obj_t* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN);
lv_obj_set_style_pad_row(wrapper, 4, LV_PART_MAIN);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_t* labelSsid = lv_label_create(wrapper);
lv_label_set_text(labelSsid, "SSID:");
lv_obj_set_style_text_color(labelSsid, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
ctx->labelSsidValue = lv_label_create(wrapper);
lv_obj_set_style_text_align(ctx->labelSsidValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_set_width(ctx->labelSsidValue, LV_PCT(100));
lv_label_set_long_mode(ctx->labelSsidValue, LV_LABEL_LONG_SCROLL);
lv_obj_set_style_margin_hor(ctx->labelSsidValue, 2, LV_PART_MAIN);
lv_obj_t* labelPassword = lv_label_create(wrapper);
lv_label_set_text(labelPassword, "Pass:");
lv_obj_set_style_text_color(labelPassword, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
ctx->labelPasswordValue = lv_label_create(wrapper);
lv_obj_set_style_text_align(ctx->labelPasswordValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_set_width(ctx->labelPasswordValue, LV_PCT(100));
lv_label_set_long_mode(ctx->labelPasswordValue, LV_LABEL_LONG_SCROLL);
lv_obj_set_style_margin_hor(ctx->labelPasswordValue, 2, LV_PART_MAIN);
lv_obj_t* labelIp = lv_label_create(wrapper);
lv_label_set_text(labelIp, "IP:");
lv_obj_set_style_text_color(labelIp, lv_palette_main(LV_PALETTE_GREY), LV_PART_MAIN);
ctx->labelIpValue = lv_label_create(wrapper);
lv_obj_set_style_text_align(ctx->labelIpValue, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN);
lv_obj_set_width(ctx->labelIpValue, LV_PCT(100));
lv_label_set_long_mode(ctx->labelIpValue, LV_LABEL_LONG_SCROLL);
lv_obj_set_style_margin_hor(ctx->labelIpValue, 2, LV_PART_MAIN);
// Start AP Mode and WebServer
settings::webserver::WebServerSettings apSettings = ctx->wsSettings;
apSettings.wifiMode = settings::webserver::WiFiMode::AccessPoint;
apSettings.webServerEnabled = true;
if (apSettings.apSsid.empty()) {
apSettings.apSsid = settings::webserver::generateDefaultApSsid();
}
// Generate password if it's an open network or if password is empty
if (apSettings.apOpenNetwork || apSettings.apPassword.empty()) {
apSettings.apPassword = settings::webserver::generateRandomCredential(12);
apSettings.apOpenNetwork = false;
}
lv_label_set_text(ctx->labelSsidValue, apSettings.apSsid.c_str());
lv_label_set_text(ctx->labelPasswordValue, apSettings.apPassword.c_str());
lv_label_set_text(ctx->labelIpValue, "192.168.4.1");
// Apply settings and start services
getMainDispatcher().dispatch([apSettings] {
if (!settings::webserver::save(apSettings)) {
LOG_E(TAG, "Failed to save AP settings");
return;
}
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
service::webserver::setWebServerEnabled(true);
});
ctx->webServerEnabledChanged = true;
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.wsSettings = settings::webserver::loadOrGetDefault();
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
// Equivalent of the old model's onHide(): persist the ORIGINAL settings (as loaded at
// startup, not the temporary AP-mode config createWidgets() applied above) and revert the
// web server's enabled state accordingly.
const auto copy = ctx.wsSettings;
const bool webServerChanged = ctx.webServerEnabledChanged;
getMainDispatcher().dispatch([copy, webServerChanged] {
if (!settings::webserver::save(copy)) {
LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot");
}
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
if (webServerChanged) {
LOG_I(TAG, "WebServer %s", copy.webServerEnabled ? "enabling..." : "disabling...");
service::webserver::setWebServerEnabled(copy.webServerEnabled);
}
});
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "ApWebServer",
.name = "AP Web Server",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace tt::app::apwebserver
@@ -1,17 +1,25 @@
#include <Tactility/Tactility.h>
#include <Tactility/PubSub.h>
#include <Tactility/app/App.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/audio/Audio.h>
#include <lvgl/icons/shared.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/sliderbox.h>
#include <lvgl/widgets/toolbar.h>
namespace tt::app::audiosettings {
class AudioSettingsApp final : public App {
extern const ::AppManifest manifest;
namespace {
struct Context {
uint32_t appInstanceId;
PubSub<service::audio::AudioEvent>::SubscriptionHandle audioSubscription = nullptr;
lv_obj_t* inputEnabledSwitch = nullptr;
@@ -21,195 +29,232 @@ class AudioSettingsApp final : public App {
lv_obj_t* outputEnabledSwitch = nullptr;
lv_obj_t* outputMuteSwitch = nullptr;
lv_obj_t* outputVolumeSlider = nullptr;
static void onInputEnabledSwitch(lv_event_t* event) {
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
service::audio::setInputEnabled(enabled);
}
static void onOutputEnabledSwitch(lv_event_t* event) {
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
service::audio::setOutputEnabled(enabled);
}
static void onInputMuteSwitch(lv_event_t* event) {
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED);
service::audio::setInputMuted(muted);
}
static void onOutputMuteSwitch(lv_event_t* event) {
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED);
service::audio::setOutputMuted(muted);
}
static void onInputVolumeSlider(lv_event_t* event) {
auto* sliderBox = static_cast<lv_obj_t*>(lv_event_get_target(event));
float percent = static_cast<float>(lvgl_sliderbox_get_value(sliderBox));
service::audio::setInputVolume(percent);
}
static void onOutputVolumeSlider(lv_event_t* event) {
auto* sliderBox = static_cast<lv_obj_t*>(lv_event_get_target(event));
float percent = static_cast<float>(lvgl_sliderbox_get_value(sliderBox));
service::audio::setOutputVolume(percent);
}
static lv_obj_t* createSection(lv_obj_t* parent, const char* title) {
auto* wrapper = lv_obj_create(parent);
lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_hor(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
auto* title_label = lv_label_create(wrapper);
lv_label_set_text(title_label, title);
return wrapper;
}
static lv_obj_t* createSwitchRow(lv_obj_t* parent, const char* label, lv_event_cb_t cb, void* userData) {
auto* row = lv_obj_create(parent);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
auto* row_label = lv_label_create(row);
lv_label_set_text(row_label, label);
lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* sw = lv_switch_create(row);
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, userData);
return sw;
}
static lv_obj_t* createSliderRow(lv_obj_t* parent, const char* label, int32_t initialValue, lv_event_cb_t cb, void* userData) {
auto* row = lv_obj_create(parent);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
auto* row_label = lv_label_create(row);
lv_label_set_text(row_label, label);
lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* sliderBox = lvgl_sliderbox_create(row, 0, 100, 10, initialValue);
lv_obj_set_width(sliderBox, LV_PCT(50));
lv_obj_align(sliderBox, LV_ALIGN_RIGHT_MID, 0, 0);
lvgl_sliderbox_add_value_changed_cb(sliderBox, cb, userData);
return sliderBox;
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
if (!service::audio::isAvailable()) {
auto* label = lv_label_create(main_wrapper);
lv_label_set_text(label, "No audio hardware available");
lv_obj_center(label);
return;
}
// Gated per-direction, not just isAvailable() - a mic-only or speaker-only
// device (e.g. a dedicated input codec with no output codec bound) should
// only show the section it actually has, not a dead section for the other.
if (service::audio::isInputAvailable()) {
auto* input_section = createSection(main_wrapper, "Microphone");
inputEnabledSwitch = createSwitchRow(input_section, "Enabled", onInputEnabledSwitch, this);
inputMuteSwitch = createSwitchRow(input_section, "Mute", onInputMuteSwitch, this);
inputVolumeSlider = createSliderRow(input_section, "Volume", static_cast<int32_t>(service::audio::getInputVolume()), onInputVolumeSlider, this);
}
if (service::audio::isOutputAvailable()) {
auto* output_section = createSection(main_wrapper, "Speaker");
outputEnabledSwitch = createSwitchRow(output_section, "Enabled", onOutputEnabledSwitch, this);
outputMuteSwitch = createSwitchRow(output_section, "Mute", onOutputMuteSwitch, this);
outputVolumeSlider = createSliderRow(output_section, "Volume", static_cast<int32_t>(service::audio::getOutputVolume()), onOutputVolumeSlider, this);
}
// isAvailable() only reflects that the audio-stream device exists, not that any
// codec is actually bound to it (the stream device is constructed unconditionally
// at module-start time, before devicetree codecs exist, and binds lazily on first
// use) -- so a board with no input or output codec at all reaches here with both
// sections skipped above and would otherwise show an empty page.
if (!service::audio::isInputAvailable() && !service::audio::isOutputAvailable()) {
auto* label = lv_label_create(main_wrapper);
lv_label_set_text(label, "No supported audio controls");
lv_obj_center(label);
}
refresh();
audioSubscription = service::audio::getPubsub()->subscribe([this](auto) {
lvgl_lock();
refresh();
lvgl_unlock();
});
}
void onHide(AppContext& app) override {
if (audioSubscription != nullptr) {
service::audio::getPubsub()->unsubscribe(audioSubscription);
audioSubscription = nullptr;
}
inputEnabledSwitch = nullptr;
inputMuteSwitch = nullptr;
inputVolumeSlider = nullptr;
outputEnabledSwitch = nullptr;
outputMuteSwitch = nullptr;
outputVolumeSlider = nullptr;
}
void refresh() const {
if (inputEnabledSwitch) {
if (service::audio::isInputEnabled()) lv_obj_add_state(inputEnabledSwitch, LV_STATE_CHECKED);
else lv_obj_remove_state(inputEnabledSwitch, LV_STATE_CHECKED);
}
if (inputMuteSwitch) {
if (service::audio::isInputMuted()) lv_obj_add_state(inputMuteSwitch, LV_STATE_CHECKED);
else lv_obj_remove_state(inputMuteSwitch, LV_STATE_CHECKED);
}
if (inputVolumeSlider) {
lvgl_sliderbox_set_value(inputVolumeSlider, static_cast<int32_t>(service::audio::getInputVolume()), LV_ANIM_OFF);
}
if (outputEnabledSwitch) {
if (service::audio::isOutputEnabled()) lv_obj_add_state(outputEnabledSwitch, LV_STATE_CHECKED);
else lv_obj_remove_state(outputEnabledSwitch, LV_STATE_CHECKED);
}
if (outputMuteSwitch) {
if (service::audio::isOutputMuted()) lv_obj_add_state(outputMuteSwitch, LV_STATE_CHECKED);
else lv_obj_remove_state(outputMuteSwitch, LV_STATE_CHECKED);
}
if (outputVolumeSlider) {
lvgl_sliderbox_set_value(outputVolumeSlider, static_cast<int32_t>(service::audio::getOutputVolume()), LV_ANIM_OFF);
}
}
};
extern const AppManifest manifest = {
.appId = "AudioSettings",
.appName = "Audio",
.appIcon = LVGL_ICON_SHARED_MUSIC_NOTE,
.appCategory = Category::Settings,
.createApp = create<AudioSettingsApp>
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onInputEnabledSwitch(lv_event_t* event) {
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
service::audio::setInputEnabled(enabled);
}
void onOutputEnabledSwitch(lv_event_t* event) {
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
service::audio::setOutputEnabled(enabled);
}
void onInputMuteSwitch(lv_event_t* event) {
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED);
service::audio::setInputMuted(muted);
}
void onOutputMuteSwitch(lv_event_t* event) {
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool muted = lv_obj_has_state(sw, LV_STATE_CHECKED);
service::audio::setOutputMuted(muted);
}
void onInputVolumeSlider(lv_event_t* event) {
auto* sliderBox = static_cast<lv_obj_t*>(lv_event_get_target(event));
float percent = static_cast<float>(lvgl_sliderbox_get_value(sliderBox));
service::audio::setInputVolume(percent);
}
void onOutputVolumeSlider(lv_event_t* event) {
auto* sliderBox = static_cast<lv_obj_t*>(lv_event_get_target(event));
float percent = static_cast<float>(lvgl_sliderbox_get_value(sliderBox));
service::audio::setOutputVolume(percent);
}
lv_obj_t* createSection(lv_obj_t* parent, const char* title) {
auto* wrapper = lv_obj_create(parent);
lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_hor(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
auto* title_label = lv_label_create(wrapper);
lv_label_set_text(title_label, title);
return wrapper;
}
lv_obj_t* createSwitchRow(lv_obj_t* parent, const char* label, lv_event_cb_t cb, void* userData) {
auto* row = lv_obj_create(parent);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
auto* row_label = lv_label_create(row);
lv_label_set_text(row_label, label);
lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* sw = lv_switch_create(row);
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(sw, cb, LV_EVENT_VALUE_CHANGED, userData);
return sw;
}
lv_obj_t* createSliderRow(lv_obj_t* parent, const char* label, int32_t initialValue, lv_event_cb_t cb, void* userData) {
auto* row = lv_obj_create(parent);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
auto* row_label = lv_label_create(row);
lv_label_set_text(row_label, label);
lv_obj_align(row_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* sliderBox = lvgl_sliderbox_create(row, 0, 100, 10, initialValue);
lv_obj_set_width(sliderBox, LV_PCT(50));
lv_obj_align(sliderBox, LV_ALIGN_RIGHT_MID, 0, 0);
lvgl_sliderbox_add_value_changed_cb(sliderBox, cb, userData);
return sliderBox;
}
void refresh(Context* ctx) {
if (ctx->inputEnabledSwitch) {
if (service::audio::isInputEnabled()) lv_obj_add_state(ctx->inputEnabledSwitch, LV_STATE_CHECKED);
else lv_obj_remove_state(ctx->inputEnabledSwitch, LV_STATE_CHECKED);
}
if (ctx->inputMuteSwitch) {
if (service::audio::isInputMuted()) lv_obj_add_state(ctx->inputMuteSwitch, LV_STATE_CHECKED);
else lv_obj_remove_state(ctx->inputMuteSwitch, LV_STATE_CHECKED);
}
if (ctx->inputVolumeSlider) {
lvgl_sliderbox_set_value(ctx->inputVolumeSlider, static_cast<int32_t>(service::audio::getInputVolume()), LV_ANIM_OFF);
}
if (ctx->outputEnabledSwitch) {
if (service::audio::isOutputEnabled()) lv_obj_add_state(ctx->outputEnabledSwitch, LV_STATE_CHECKED);
else lv_obj_remove_state(ctx->outputEnabledSwitch, LV_STATE_CHECKED);
}
if (ctx->outputMuteSwitch) {
if (service::audio::isOutputMuted()) lv_obj_add_state(ctx->outputMuteSwitch, LV_STATE_CHECKED);
else lv_obj_remove_state(ctx->outputMuteSwitch, LV_STATE_CHECKED);
}
if (ctx->outputVolumeSlider) {
lvgl_sliderbox_set_value(ctx->outputVolumeSlider, static_cast<int32_t>(service::audio::getOutputVolume()), LV_ANIM_OFF);
}
}
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, "Audio");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
if (!service::audio::isAvailable()) {
auto* label = lv_label_create(main_wrapper);
lv_label_set_text(label, "No audio hardware available");
lv_obj_center(label);
return;
}
// Gated per-direction, not just isAvailable() - a mic-only or speaker-only
// device (e.g. a dedicated input codec with no output codec bound) should
// only show the section it actually has, not a dead section for the other.
if (service::audio::isInputAvailable()) {
auto* input_section = createSection(main_wrapper, "Microphone");
ctx->inputEnabledSwitch = createSwitchRow(input_section, "Enabled", onInputEnabledSwitch, ctx);
ctx->inputMuteSwitch = createSwitchRow(input_section, "Mute", onInputMuteSwitch, ctx);
ctx->inputVolumeSlider = createSliderRow(input_section, "Volume", static_cast<int32_t>(service::audio::getInputVolume()), onInputVolumeSlider, ctx);
}
if (service::audio::isOutputAvailable()) {
auto* output_section = createSection(main_wrapper, "Speaker");
ctx->outputEnabledSwitch = createSwitchRow(output_section, "Enabled", onOutputEnabledSwitch, ctx);
ctx->outputMuteSwitch = createSwitchRow(output_section, "Mute", onOutputMuteSwitch, ctx);
ctx->outputVolumeSlider = createSliderRow(output_section, "Volume", static_cast<int32_t>(service::audio::getOutputVolume()), onOutputVolumeSlider, ctx);
}
// isAvailable() only reflects that the audio-stream device exists, not that any
// codec is actually bound to it (the stream device is constructed unconditionally
// at module-start time, before devicetree codecs exist, and binds lazily on first
// use) -- so a board with no input or output codec at all reaches here with both
// sections skipped above and would otherwise show an empty page.
if (!service::audio::isInputAvailable() && !service::audio::isOutputAvailable()) {
auto* label = lv_label_create(main_wrapper);
lv_label_set_text(label, "No supported audio controls");
lv_obj_center(label);
}
refresh(ctx);
ctx->audioSubscription = service::audio::getPubsub()->subscribe([ctx](auto) {
lvgl_lock();
refresh(ctx);
lvgl_unlock();
});
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
if (ctx.audioSubscription != nullptr) {
service::audio::getPubsub()->unsubscribe(ctx.audioSubscription);
ctx.audioSubscription = nullptr;
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "AudioSettings",
.name = "Audio",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace tt::app::audiosettings
+272 -216
View File
@@ -1,28 +1,30 @@
#include "tactility/system_event.h"
#include <tactility/delay.h>
#include <tactility/drivers/backlight.h>
#include <tactility/drivers/display.h>
#include <tactility/log.h>
#include <tactility/time.h>
#include <Tactility/CpuAffinity.h>
#include <Tactility/Paths.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/MountPoints.h>
#include <Tactility/TactilityPrivate.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppPaths.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/hal/usb/Usb.h>
#include <Tactility/lvgl/Lvgl.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/settings/BootSettings.h>
#include <Tactility/settings/DisplaySettings.h>
#include <lvgl.h>
#include <atomic>
#include <format>
#ifdef ESP_PLATFORM
#include <Tactility/app/crashdiagnostics/CrashDiagnostics.h>
@@ -37,243 +39,297 @@ namespace tt::app::boot {
constexpr auto* TAG = "Boot";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
class BootApp : public App {
namespace {
// Snapshot of hal::usb::isUsbBootMode(), taken before the boot thread starts and
// potentially clears the underlying flag via setupUsbBootMode()/resetUsbBootMode().
// onShow() reads this instead of the live flag to avoid a race between the two.
static std::atomic<bool> isUsbBootSplash;
// Snapshot of hal::usb::isUsbBootMode(), taken before boot work starts and potentially clears
// the underlying flag via setupUsbBootMode()/resetUsbBootMode(). createSplashWidgets() reads
// this instead of the live flag to avoid a race between the two.
std::atomic<bool> isUsbBootSplash = false;
// Set by bootThreadCallback() when CONFIG_TT_USER_DATA_LOCATION_SD is defined but no SD card is mounted.
// onShow() reads this to show an error instead of the normal splash, and boot halts instead of starting the launcher.
static std::atomic<bool> sdCardMissing;
// Set when CONFIG_TT_USER_DATA_LOCATION_SD is defined but no SD card is mounted. Switches the
// window to an error screen and halts before starting the next app.
std::atomic<bool> sdCardMissing = false;
Thread thread = Thread(
"boot",
5120,
[] { return bootThreadCallback(); },
getCpuAffinityConfiguration().system
);
static void setupDisplay() {
Device* display = nullptr;
if (device_get_first_by_type(&DISPLAY_TYPE, &display) == ERROR_NONE) {
Device* backlight;
if (display_get_backlight(display, &backlight) == ERROR_NONE) {
if (!device_is_ready(backlight)) {
if (device_start(backlight) != ERROR_NONE) {
LOG_E(TAG, "Failed to start %s", backlight->name);
}
}
settings::display::DisplaySettings settings;
if (settings::display::load(settings)) {
} else {
settings = settings::display::getDefault();
}
if (backlight_set_brightness(backlight, settings.backlightDuty) == ERROR_NONE) {
LOG_I(TAG, "Backlight for %s set to %d", display->name, settings.backlightDuty);
} else {
LOG_E(TAG, "Failed to set brightness of %s", backlight->name);
}
} else {
LOG_I(TAG, "No backlight for %s", display->name);
}
device_put(display);
} else {
LOG_I(TAG, "No kernel display");
}
}
static bool setupUsbBootMode() {
if (!hal::usb::isUsbBootMode()) {
return false;
}
LOG_I(TAG, "Rebooting into mass storage device mode");
auto mode = hal::usb::getUsbBootMode(); // Get mode before reset
hal::usb::resetUsbBootMode();
if (mode == hal::usb::BootMode::Flash) {
if (!hal::usb::startMassStorageWithFlash(true)) {
LOG_E(TAG, "Unable to start flash mass storage");
return false;
}
} else if (mode == hal::usb::BootMode::Sdmmc) {
if (!hal::usb::startMassStorageWithSdmmc(true)) {
LOG_E(TAG, "Unable to start SD mass storage");
return false;
}
}
return true;
}
static void waitForMinimalSplashDuration(TickType_t startTime) {
const auto end_time = get_ticks();
const auto ticks_passed = end_time - startTime;
constexpr auto minimum_ticks = (CONFIG_TT_SPLASH_DURATION / portTICK_PERIOD_MS);
if (minimum_ticks > ticks_passed) {
delay_ticks(minimum_ticks - ticks_passed);
}
}
static int32_t bootThreadCallback() {
LOG_I(TAG, "Starting boot thread");
const auto start_time = get_ticks();
// Give the UI some time to redraw
// If we don't do this, various init calls will read files and block SPI IO for the display
// This would result in a blank/black screen being shown during this phase of the boot process
// This works with 5 ms on a T-Lora Pager, so we give it 10 ms to be safe
delay_millis(10);
// TODO: Support for multiple displays
LOG_I(TAG, "Setup display");
setupDisplay();
LOG_I(TAG, "Prepare file systems");
prepareFileSystems();
#ifdef CONFIG_TT_USER_DATA_LOCATION_SD
std::string sd_path;
if (!findFirstMountedSdCardPath(sd_path)) {
LOG_E(TAG, "SD card not found");
sdCardMissing = true;
}
#endif
if (!setupUsbBootMode()) {
LOG_I(TAG, "initFromBootApp");
registerApps();
waitForMinimalSplashDuration(start_time);
// When SD card is missing, wait for dialog result
if (!sdCardMissing) stop(manifest.appId);
startNextApp();
}
// This event will likely block as other systems are initialized
// e.g. Wi-Fi reads AP configs from SD card
LOG_I(TAG, "Publish event");
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
return 0;
}
static std::string getLauncherAppId() {
settings::BootSettings boot_properties;
// When boot.properties hasn't been overridden, return default
if (!settings::loadBootSettings(boot_properties)) {
return CONFIG_TT_LAUNCHER_APP_ID;
}
// When boot properties didn't specify an override, return default
if (boot_properties.launcherAppId.empty()) {
LOG_E(TAG, "Failed to load launcher configuration, or launcher not configured");
return CONFIG_TT_LAUNCHER_APP_ID;
}
// If the app in the boot.properties does not exist, return default
if (findAppManifestById(boot_properties.launcherAppId) == nullptr) {
LOG_E(TAG, "Launcher app %s not found", boot_properties.launcherAppId.c_str());
return CONFIG_TT_LAUNCHER_APP_ID;
}
// The boot.properties launcher app id is valid
return boot_properties.launcherAppId;
}
static void startNextApp() {
if (sdCardMissing) {
alertdialog::start("Error", "SD card not found.\nPlease insert one and reboot.", std::vector<const char*> { "Reboot" });
return;
}
uint32_t bootAppInstanceId = 0;
WindowId bootWindowId = 0;
#ifdef ESP_PLATFORM
if (esp_reset_reason() == ESP_RST_PANIC) {
crashdiagnostics::start();
return;
}
constexpr auto PARTITION_PREFIX = std::string("/");
#else
constexpr auto PARTITION_PREFIX = std::string("");
#endif
auto launcher_app_id = getLauncherAppId();
start(launcher_app_id);
// Equivalent of AppPaths::getAssetsPath() for the internal "Boot" app id, without needing a
// live AppContext (which this app no longer has under the new app-module model).
std::string getBootAssetsPath(const std::string& childPath) {
return std::format("{}{}/app/Boot/assets/{}", PARTITION_PREFIX, file::SYSTEM_PARTITION_NAME, childPath);
}
void setupDisplay() {
// TODO: Support for multiple displays
Device* display = nullptr;
if (device_get_first_by_type(&DISPLAY_TYPE, &display) != ERROR_NONE) {
LOG_I(TAG, "No kernel display");
return;
}
static int getSmallestDimension() {
auto* display = lv_display_get_default();
int width = lv_display_get_horizontal_resolution(display);
int height = lv_display_get_vertical_resolution(display);
return std::min(width, height);
// Set backlight brightness
Device* backlight;
if (display_get_backlight(display, &backlight) == ERROR_NONE) {
if (!device_is_ready(backlight)) {
if (device_start(backlight) != ERROR_NONE) {
LOG_E(TAG, "Failed to start %s", backlight->name);
}
}
settings::display::DisplaySettings settings;
if (settings::display::load(settings)) {
} else {
settings = settings::display::getDefault();
}
if (backlight_set_brightness(backlight, settings.backlightDuty) == ERROR_NONE) {
LOG_I(TAG, "Backlight for %s set to %d", display->name, settings.backlightDuty);
} else {
LOG_E(TAG, "Failed to set brightness of %s", backlight->name);
}
} else {
LOG_I(TAG, "No backlight for %s", display->name);
}
public:
device_put(display);
}
void onCreate(AppContext& app) override {
// Snapshot before the boot thread potentially clears the flag via setupUsbBootMode()
isUsbBootSplash = hal::usb::isUsbBootMode();
bool setupUsbBootMode() {
if (!hal::usb::isUsbBootMode()) {
return false;
}
// Just in case this app is somehow resumed
if (thread.getState() == Thread::State::Stopped) {
thread.start();
LOG_I(TAG, "Rebooting into mass storage device mode");
auto mode = hal::usb::getUsbBootMode(); // Get mode before reset
hal::usb::resetUsbBootMode();
if (mode == hal::usb::BootMode::Flash) {
if (!hal::usb::startMassStorageWithFlash(true)) {
LOG_E(TAG, "Unable to start flash mass storage");
return false;
}
} else if (mode == hal::usb::BootMode::Sdmmc) {
if (!hal::usb::startMassStorageWithSdmmc(true)) {
LOG_E(TAG, "Unable to start SD mass storage");
return false;
}
}
void onDestroy(AppContext& app) override {
thread.join();
return true;
}
void waitForMinimalSplashDuration(TickType_t startTime) {
const auto end_time = get_ticks();
const auto ticks_passed = end_time - startTime;
constexpr auto minimum_ticks = (CONFIG_TT_SPLASH_DURATION / portTICK_PERIOD_MS);
if (minimum_ticks > ticks_passed) {
delay_ticks(minimum_ticks - ticks_passed);
}
}
std::string getLauncherAppId() {
settings::BootSettings boot_properties;
// When boot.properties hasn't been overridden, return default
if (!settings::loadBootSettings(boot_properties)) {
return CONFIG_TT_LAUNCHER_APP_ID;
}
void onResult(AppContext& /*app*/, LaunchId /*launchId*/, Result /*result*/, std::unique_ptr<Bundle> /*bundle*/) override {
// When boot properties didn't specify an override, return default
if (boot_properties.launcherAppId.empty()) {
LOG_E(TAG, "Failed to load launcher configuration, or launcher not configured");
return CONFIG_TT_LAUNCHER_APP_ID;
}
// If the app in the boot.properties does not exist, return default
if (app_manager_find_manifest(boot_properties.launcherAppId.c_str()) == nullptr) {
LOG_E(TAG, "Launcher app %s not found", boot_properties.launcherAppId.c_str());
return CONFIG_TT_LAUNCHER_APP_ID;
}
// The boot.properties launcher app id is valid
return boot_properties.launcherAppId;
}
int getSmallestDimension() {
auto* display = lv_display_get_default();
int width = lv_display_get_horizontal_resolution(display);
int height = lv_display_get_vertical_resolution(display);
return std::min(width, height);
}
void createSplashWidgets(lv_obj_t* root, void*) {
lvgl::obj_set_style_bg_blacken(root);
lv_obj_set_style_border_width(root, 0, LV_STATE_DEFAULT);
lv_obj_set_style_radius(root, 0, LV_STATE_DEFAULT);
auto* image = lv_image_create(root);
lv_obj_set_size(image, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_align(image, LV_ALIGN_CENTER, 0, 0);
const char* logo;
// TODO: Replace with automatic asset buckets like on Android
if (getSmallestDimension() < 150) { // e.g. Cardputer
logo = isUsbBootSplash ? "logo_usb.png" : "logo_small.png";
} else {
logo = isUsbBootSplash ? "logo_usb.png" : "logo.png";
}
const auto logo_path = lvgl::PATH_PREFIX + getBootAssetsPath(logo);
LOG_I(TAG, "%s", logo_path.c_str());
lv_image_set_src(image, logo_path.c_str());
#ifdef ESP_PLATFORM
if (isUsbBootSplash) {
auto* button = lv_button_create(root);
lv_obj_align(button, LV_ALIGN_BOTTOM_MID, 0, -16);
auto* label = lv_label_create(button);
lv_label_set_text(label, "Return to OS");
lv_obj_add_event_cb(button, [](lv_event_t*) {
hal::usb::stop();
esp_restart();
}, LV_EVENT_SHORT_CLICKED, nullptr);
}
#endif
}
void createSdCardMissingWidgets(lv_obj_t* root, void*) {
lvgl::obj_set_style_bg_blacken(root);
lv_obj_set_style_border_width(root, 0, LV_STATE_DEFAULT);
lv_obj_set_style_radius(root, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(root, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(root, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
auto* label = lv_label_create(root);
lv_label_set_text(label, "SD card not found.\nPlease insert one and reboot.");
lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, LV_STATE_DEFAULT);
lv_obj_set_style_text_color(label, lv_color_white(), LV_STATE_DEFAULT);
auto* button = lv_button_create(root);
lv_obj_set_style_margin_top(button, 16, LV_STATE_DEFAULT);
auto* button_label = lv_label_create(button);
lv_label_set_text(button_label, "Reboot");
lv_obj_add_event_cb(button, [](lv_event_t*) {
#ifdef ESP_PLATFORM
esp_restart();
#endif
}, LV_EVENT_SHORT_CLICKED, nullptr);
}
// Replaces the splash with a self-contained error screen (no dependency on the old alertdialog
// app - this app has no parent in the old App stack to deliver a result back to).
void showSdCardMissingScreen() {
if (bootWindowId != 0) {
window_manager_remove(bootWindowId);
}
bootWindowId = window_manager_create(bootAppInstanceId, createSdCardMissingWidgets, nullptr);
}
void onShow(AppContext& app, lv_obj_t* parent) override {
lvgl::obj_set_style_bg_blacken(parent);
lv_obj_set_style_border_width(parent, 0, LV_STATE_DEFAULT);
lv_obj_set_style_radius(parent, 0, LV_STATE_DEFAULT);
auto* image = lv_image_create(parent);
lv_obj_set_size(image, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_align(image, LV_ALIGN_CENTER, 0, 0);
const auto paths = app.getPaths();
const char* logo;
// TODO: Replace with automatic asset buckets like on Android
if (getSmallestDimension() < 150) { // e.g. Cardputer
logo = isUsbBootSplash ? "logo_usb.png" : "logo_small.png";
} else {
logo = isUsbBootSplash ? "logo_usb.png" : "logo.png";
}
const auto logo_path = lvgl::PATH_PREFIX + paths->getAssetsPath(logo);
LOG_I(TAG, "%s", logo_path.c_str());
lv_image_set_src(image, logo_path.c_str());
void startNextApp() {
if (sdCardMissing) {
showSdCardMissingScreen();
return;
}
#ifdef ESP_PLATFORM
if (isUsbBootSplash) {
auto* button = lv_button_create(parent);
lv_obj_align(button, LV_ALIGN_BOTTOM_MID, 0, -16);
auto* label = lv_label_create(button);
lv_label_set_text(label, "Return to OS");
lv_obj_add_event_cb(button, [](lv_event_t*) {
hal::usb::stop();
esp_restart();
}, LV_EVENT_SHORT_CLICKED, nullptr);
}
#endif
if (esp_reset_reason() == ESP_RST_PANIC) {
crashdiagnostics::start(); // fire-and-forget; no result expected back
return;
}
};
#endif
std::atomic<bool> BootApp::isUsbBootSplash = false;
std::atomic<bool> BootApp::sdCardMissing = false;
auto launcher_app_id = getLauncherAppId();
uint32_t launcher_instance_id = 0;
app_manager_start(launcher_app_id.c_str(), &launcher_instance_id);
}
extern const AppManifest manifest = {
.appId = "Boot",
.appName = "Boot",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::HideStatusBar | AppManifest::Flags::Hidden,
.createApp = create<BootApp>
void runBootSequence(TickType_t startTime) {
LOG_I(TAG, "Starting boot sequence");
// Give the UI some time to redraw
// If we don't do this, various init calls will read files and block SPI IO for the display
// This would result in a blank/black screen being shown during this phase of the boot process
// This works with 5 ms on a T-Lora Pager, so we give it 10 ms to be safe
delay_millis(10);
LOG_I(TAG, "Setup display");
setupDisplay();
LOG_I(TAG, "Prepare file systems");
prepareFileSystems();
#ifdef CONFIG_TT_USER_DATA_LOCATION_SD
std::string sd_path;
if (!findFirstMountedSdCardPath(sd_path)) {
LOG_E(TAG, "SD card not found");
sdCardMissing = true;
}
#endif
if (!setupUsbBootMode()) {
LOG_I(TAG, "initFromBootApp");
registerApps();
waitForMinimalSplashDuration(startTime);
startNextApp();
}
// This event will likely block as other systems are initialized
// e.g. Wi-Fi reads AP configs from SD card
LOG_I(TAG, "Publish event");
system_event_emit(KERNEL_EVENT_BOOT_COMPLETED, nullptr, 0);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
bootAppInstanceId = appInstanceId;
const auto start_time = get_ticks();
// Snapshot before runBootSequence() potentially clears the flag via setupUsbBootMode()
isUsbBootSplash = hal::usb::isUsbBootMode();
sdCardMissing = false;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
bootWindowId = window_manager_create(appInstanceId, createSplashWidgets, nullptr);
runBootSequence(start_time);
// Waits until app_manager_start(launcher) (or a permanent stop) tells us to give up -
// startNextApp() above is what triggers that, via app-module's "save the previously active
// app" policy, unless sdCardMissing halted before it.
while (true) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
if (event.type == APP_EVENT_CLOSE) {
app_manager_finish(appInstanceId);
break;
}
}
if (bootWindowId != 0) {
window_manager_remove(bootWindowId);
}
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "Boot",
.name = "Boot",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
+140 -117
View File
@@ -4,20 +4,25 @@
#include <Tactility/app/btmanage/View.h>
#include <Tactility/Tactility.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppManifest.h>
#include <lvgl/icons/shared.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
namespace tt::app::btmanage {
constexpr auto* TAG = "BtManage";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
static void onBtToggled(bool requestOn) {
static void onBtToggled(void* context, bool requestOn) {
#if defined(CONFIG_BT_NIMBLE_ENABLED)
auto* ctx = static_cast<Context*>(context);
Device* dev;
if (device_get_first_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE) {
bool radio_on = bluetooth::isRadioOnOrPending(dev);
@@ -25,17 +30,15 @@ static void onBtToggled(bool requestOn) {
LOG_I(TAG, "Turning on");
if (bluetooth::start(dev)) {
// The driver only allocates its callback list once the device is started,
// so the registration attempted in onShow() (while radio was off) was a
// so the registration attempted at startup (while radio was off) was a
// no-op. Register again now that the device is actually up.
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
bt->registerDeviceCallback(dev);
registerDeviceCallback(ctx, dev);
}
} else if (!requestOn && radio_on) {
LOG_I(TAG, "Turning off");
if (bluetooth::stop(dev)) {
// A completed stop frees the driver's callback list.
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
bt->forgetCallbackRegistration();
forgetCallbackRegistration(ctx);
}
}
device_put(dev);
@@ -46,7 +49,7 @@ static void onBtToggled(bool requestOn) {
#endif
}
static void onScanToggled(bool enabled) {
static void onScanToggled(void* /*context*/, bool enabled) {
Device* dev;
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) != ERROR_NONE) {
LOG_W(TAG, "Scan: No bluetooth device found");
@@ -70,7 +73,7 @@ static void onDisconnectPeer(const std::array<uint8_t, 6>& addr, int profileId)
bluetooth::disconnect(addr, profileId);
}
static void onPairPeer(const std::array<uint8_t, 6>& addr) {
static void onPairPeer(void* /*context*/, const std::array<uint8_t, 6>& addr) {
// Clicking an unrecognised scan result initiates a HID host connection.
// Bond exchange happens automatically during the first connection.
bluetooth::hidHostConnect(addr);
@@ -80,67 +83,48 @@ static void onForgetPeer(const std::array<uint8_t, 6>& addr) {
bluetooth::unpair(addr);
}
BtManage::BtManage() {
bindings = (Bindings) {
.onBtToggled = onBtToggled,
.onScanToggled = onScanToggled,
.onConnectPeer = onConnectPeer,
.onDisconnectPeer = onDisconnectPeer,
.onPairPeer = onPairPeer,
.onForgetPeer = onForgetPeer,
};
}
static void onKernelBtEvent(Device* /*device*/, void* context, BtEvent event);
void BtManage::lock() {
mutex.lock();
}
void BtManage::unlock() {
mutex.unlock();
}
void BtManage::requestViewUpdate() {
// Lock order must match onShow()/onHide(): both run under GuiService's lvgl_lock()
// and then take `mutex` internally. Taking `mutex` before lvgl_lock() here would
// invert that order and deadlock against a concurrent onHide()/onShow() (GUI task
// holding LVGL lock, waiting on `mutex`; this task holding `mutex`, waiting on LVGL
// lock) - exactly what happens when BT events fire rapidly (e.g. during scanning)
// while the app is being hidden.
void requestViewUpdate(Context* ctx) {
// Lock order must match appMain()'s setup/teardown: both run under the LVGL lock
// and then take `ctx->mutex` internally. Taking `mutex` before lvgl_lock() here would
// invert that order and deadlock against a concurrent teardown (GUI task holding the
// LVGL lock, waiting on `mutex`; this task holding `mutex`, waiting on the LVGL lock) -
// exactly what happens when BT events fire rapidly (e.g. during scanning) while the app
// is closing.
lvgl_lock();
lock();
if (isViewEnabled) {
view.update();
}
unlock();
ctx->lock();
ctx->view.update();
ctx->unlock();
lvgl_unlock();
}
void BtManage::onBtEvent(const BtEvent& event) {
void onBtEvent(Context* ctx, const BtEvent& event) {
auto radio_state = bluetooth::getRadioState();
LOG_I(TAG, "Update with state %s", bluetooth::radioStateToString(radio_state));
getState().setRadioState(radio_state);
ctx->state.setRadioState(radio_state);
switch (event.type) {
case BT_EVENT_SCAN_STARTED:
getState().setScanning(true);
ctx->state.setScanning(true);
break;
case BT_EVENT_SCAN_FINISHED:
getState().setScanning(false);
getState().updateScanResults();
getState().updatePairedPeers();
ctx->state.setScanning(false);
ctx->state.updateScanResults();
ctx->state.updatePairedPeers();
break;
case BT_EVENT_PEER_FOUND:
getState().updateScanResults();
ctx->state.updateScanResults();
break;
case BT_EVENT_PAIR_RESULT:
getState().updatePairedPeers();
ctx->state.updatePairedPeers();
break;
case BT_EVENT_PROFILE_STATE_CHANGED:
getState().updateScanResults();
getState().updatePairedPeers();
ctx->state.updateScanResults();
ctx->state.updatePairedPeers();
break;
case BT_EVENT_RADIO_STATE_CHANGED:
if (event.radio_state == BT_RADIO_STATE_ON) {
getState().updatePairedPeers();
ctx->state.updatePairedPeers();
Device* dev = nullptr;
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE && !bluetooth_is_scanning(dev)) {
bluetooth_scan_start(dev);
@@ -154,7 +138,7 @@ void BtManage::onBtEvent(const BtEvent& event) {
break;
}
requestViewUpdate();
requestViewUpdate(ctx);
}
static void onKernelBtEvent(Device* /*device*/, void* context, BtEvent event) {
@@ -163,65 +147,88 @@ static void onKernelBtEvent(Device* /*device*/, void* context, BtEvent event) {
// task would block it on the LVGL mutex (held by the LVGL task waiting in
// nimble_port_stop), creating a permanent deadlock. Dispatch to the main task so
// the NimBLE host task is never blocked by BtManage's state updates or LVGL lock.
auto* self = static_cast<BtManage*>(context);
// Captured while `self` is still guaranteed valid (the callback is only invoked
// while registered, i.e. before onHide() removes it). Comparing this later - without
// dereferencing `self` - lets the dispatched lambda detect a stale event from a
// session that has since been hidden (and possibly destroyed) without a UAF.
auto generation = self->getGeneration();
auto* ctx = static_cast<Context*>(context);
// Captured while `ctx` is still guaranteed valid (the callback is only invoked while
// registered, i.e. before appMain()'s cleanup removes it). Comparing this later -
// without dereferencing `ctx` - lets the dispatched lambda detect a stale event from an
// instance that has since closed (and had its Context destroyed) without a UAF: the
// generation bump in appMain()'s cleanup always happens before window_manager_remove()
// destroys ctx's widgets, and this dispatched lambda always re-reads the live generation
// at run time (not at dispatch time), so a bump landing anywhere before this lambda
// actually runs is enough to make it skip touching ctx.
auto generation = ctx->generation;
int expectedGeneration = generation->load();
getMainDispatcher().dispatch([self, generation, expectedGeneration, event] {
getMainDispatcher().dispatch([ctx, generation, expectedGeneration, event] {
if (generation->load() != expectedGeneration) {
return;
}
self->onBtEvent(event);
onBtEvent(ctx, event);
});
}
void BtManage::registerDeviceCallback(Device* dev) {
lock();
if (btDevice == dev && !callbackRegistered) {
void registerDeviceCallback(Context* ctx, Device* dev) {
ctx->lock();
if (ctx->btDevice == dev && !ctx->callbackRegistered) {
// Only latch the flag on success: while the radio is off the driver has no
// callback list yet, so this add is a silent no-op and must be retried once
// bluetooth::start() actually brings the device up.
if (bluetooth_add_event_callback(dev, this, onKernelBtEvent) == ERROR_NONE) {
callbackRegistered = true;
if (bluetooth_add_event_callback(dev, ctx, onKernelBtEvent) == ERROR_NONE) {
ctx->callbackRegistered = true;
}
}
unlock();
ctx->unlock();
}
void BtManage::forgetCallbackRegistration() {
lock();
callbackRegistered = false;
unlock();
void forgetCallbackRegistration(Context* ctx) {
ctx->lock();
ctx->callbackRegistered = false;
ctx->unlock();
}
void BtManage::onShow(AppContext& app, lv_obj_t* parent) {
// Initialise state and view before subscribing to avoid incoming events
// racing with state initialisation.
state.setRadioState(bluetooth::getRadioState());
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->lock();
ctx->view.init(ctx, parent);
ctx->view.update();
ctx->unlock();
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx;
ctx.appInstanceId = appInstanceId;
ctx.bindings = (Bindings) {
.onBtToggled = onBtToggled,
.onScanToggled = onScanToggled,
.onConnectPeer = onConnectPeer,
.onDisconnectPeer = onDisconnectPeer,
.onPairPeer = onPairPeer,
.onForgetPeer = onForgetPeer,
};
// Initialise state before subscribing to avoid incoming events racing with it.
ctx.state.setRadioState(bluetooth::getRadioState());
Device* dev = nullptr;
device_get_first_by_type(&BLUETOOTH_TYPE, &dev);
state.setScanning(dev ? bluetooth_is_scanning(dev) : false);
state.updateScanResults();
state.updatePairedPeers();
ctx.state.setScanning(dev ? bluetooth_is_scanning(dev) : false);
ctx.state.updateScanResults();
ctx.state.updatePairedPeers();
lock();
isViewEnabled = true;
view.init(app, parent);
view.update();
unlock();
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
if (btDevice) {
// Decrease refcount before re-ssignment
device_put(btDevice);
}
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
btDevice = dev;
if (btDevice) {
registerDeviceCallback(btDevice);
ctx.btDevice = dev;
if (ctx.btDevice) {
registerDeviceCallback(&ctx, ctx.btDevice);
}
auto radio_state = bluetooth::getRadioState();
@@ -233,37 +240,53 @@ void BtManage::onShow(AppContext& app, lv_obj_t* parent) {
if (can_scan && dev && !bluetooth_is_scanning(dev)) {
bluetooth_scan_start(dev);
}
}
void BtManage::onHide(AppContext& app) {
// Invalidate any BT event dispatched-but-not-yet-run for this session before doing
// anything else, so it can't race a subsequent destruction of this instance (see
// onKernelBtEvent()/getGeneration()).
generation->fetch_add(1);
lock();
if (btDevice) {
if (callbackRegistered) {
bluetooth_remove_event_callback(btDevice, onKernelBtEvent);
callbackRegistered = false;
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
device_put(btDevice);
btDevice = nullptr;
}
isViewEnabled = false;
unlock();
// Invalidate any BT event dispatched-but-not-yet-run for this instance before doing
// anything else, so it can't race the teardown below (see onKernelBtEvent()).
ctx.generation->fetch_add(1);
if (ctx.btDevice) {
if (ctx.callbackRegistered) {
bluetooth_remove_event_callback(ctx.btDevice, onKernelBtEvent);
ctx.callbackRegistered = false;
}
device_put(ctx.btDevice);
ctx.btDevice = nullptr;
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
extern const AppManifest manifest = {
.appId = "BtManage",
.appName = "Bluetooth",
.appIcon = LVGL_ICON_SHARED_BLUETOOTH,
.appCategory = Category::Settings,
.createApp = create<BtManage>
uint32_t start() {
uint32_t instanceId = 0;
app_manager_start(manifest.id, &instanceId);
return instanceId;
}
extern const ::AppManifest manifest = {
.id = "BtManage",
.name = "Bluetooth",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
LaunchId start() {
return app::start(manifest.appId);
}
} // namespace tt::app::btmanage
+31 -15
View File
@@ -13,13 +13,26 @@
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
#include <Tactility/Tactility.h>
#include <app/event.h>
#include <lvgl/widgets/toolbar.h>
namespace tt::app::btmanage {
static void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
static void onEnableSwitchChanged(lv_event_t* event) {
auto* enable_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
bt->getBindings().onBtToggled(is_on);
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->bindings.onBtToggled(ctx, is_on);
}
static void onEnableOnBootSwitchChanged(lv_event_t* event) {
@@ -45,39 +58,40 @@ static void onEnableOnBootParentClicked(lv_event_t* event) {
}
static void onScanButtonClicked(lv_event_t* event) {
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
Device* dev = nullptr;
device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev);
bool scanning = dev ? bluetooth_is_scanning(dev) : false;
if (dev) {
device_put(dev);
}
bt->getBindings().onScanToggled(!scanning);
ctx->bindings.onScanToggled(ctx, !scanning);
}
// region Peer list callbacks
struct PeerListItemData {
void* context;
State* state;
Bindings* bindings;
size_t index;
bool isPaired;
};
void View::onConnect(lv_event_t* event) {
auto* data = static_cast<PeerListItemData*>(lv_event_get_user_data(event));
auto bt = std::static_pointer_cast<BtManage>(getCurrentApp());
auto& state = bt->getState();
if (data->isPaired) {
// Open the per-device settings screen for paired devices
auto peers = state.getPairedPeers();
auto peers = data->state->getPairedPeers();
if (data->index < peers.size()) {
btpeersettings::start(bluetooth::settings::addrToHex(peers[data->index].addr));
}
} else {
// Unrecognised scan result — initiate pairing
auto peers = state.getScanResults();
auto peers = data->state->getScanResults();
if (data->index < peers.size()) {
bt->getBindings().onPairPeer(peers[data->index].addr);
data->bindings->onPairPeer(data->context, peers[data->index].addr);
}
}
}
@@ -102,7 +116,7 @@ void View::createPeerListItem(const bluetooth::PeerRecord& record, bool isPaired
auto* button = lv_list_add_button(peers_list, nullptr, label.c_str());
auto* item_data = new PeerListItemData { index, isPaired };
auto* item_data = new PeerListItemData { context, state, bindings, index, isPaired };
lv_obj_set_user_data(button, item_data);
lv_obj_add_event_cb(button, onConnect, LV_EVENT_SHORT_CLICKED, item_data);
lv_obj_add_event_cb(button, [](lv_event_t* e) {
@@ -210,26 +224,28 @@ void View::updatePeerList() {
lv_obj_set_style_margin_ver(scan_button, 4, LV_STATE_DEFAULT);
auto* scan_label = lv_label_create(scan_button);
lv_label_set_text(scan_label, state->isScanning() ? "Stop scan" : "Scan");
lv_obj_add_event_cb(scan_button, onScanButtonClicked, LV_EVENT_SHORT_CLICKED, nullptr);
lv_obj_add_event_cb(scan_button, onScanButtonClicked, LV_EVENT_SHORT_CLICKED, context);
}
}
// endregion Secondary updates
void View::init(const AppContext& app, lv_obj_t* parent) {
void View::init(void* newContext, lv_obj_t* parent) {
context = newContext;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
root = parent;
paths = app.getPaths();
// Toolbar
auto* toolbar = lvgl::toolbar_create(parent, app);
auto* toolbar = lvgl_toolbar_create(parent, "Bluetooth");
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, context);
scanning_spinner = lvgl_toolbar_add_spinner_action(toolbar);
enable_switch = lvgl_toolbar_add_switch_action(toolbar);
lv_obj_add_event_cb(enable_switch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, nullptr);
lv_obj_add_event_cb(enable_switch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, context);
// Peer list
peers_list = lv_list_create(parent);
@@ -3,15 +3,17 @@
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <Tactility/app/App.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/bluetooth/Bluetooth.h>
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
#include <Tactility/lvgl/Style.h>
#include <tactility/check.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/device.h>
#include <tactility/drivers/bluetooth.h>
#include <tactility/log.h>
@@ -20,211 +22,245 @@ namespace tt::app::btpeersettings {
constexpr auto* TAG = "BtPeerSettings";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
void start(const std::string& addrHex) {
auto bundle = std::make_shared<Bundle>();
bundle->putString("addr", addrHex);
app::start(manifest.appId, bundle);
}
namespace {
class BtPeerSettings : public App {
bool viewEnabled = false;
lv_obj_t* connectButton = nullptr;
lv_obj_t* disconnectButton = nullptr;
struct Context {
uint32_t appInstanceId;
std::string addrHex;
std::array<uint8_t, 6> addr = {};
int profileId = BT_PROFILE_HID_HOST;
bool isCurrentlyConnected() const {
for (const auto& p : bluetooth::getPairedPeers()) {
if (p.addr == addr) return p.connected;
}
return false;
}
static void onPressConnect(lv_event_t* event) {
auto* self = static_cast<BtPeerSettings*>(lv_event_get_user_data(event));
if (self->profileId == BT_PROFILE_HID_HOST) {
bluetooth::hidHostConnect(self->addr);
} else {
bluetooth::connect(self->addr, self->profileId);
}
lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED);
}
static void onPressDisconnect(lv_event_t* event) {
auto* self = static_cast<BtPeerSettings*>(lv_event_get_user_data(event));
if (self->profileId == BT_PROFILE_HID_HOST) {
bluetooth::hidHostDisconnect();
} else {
bluetooth::disconnect(self->addr, self->profileId);
}
lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED);
}
static void onPressForget(lv_event_t* event) {
std::vector<std::string> choices = { "Yes", "No" };
alertdialog::start("Confirmation", "Forget this device?", choices);
}
static void onToggleAutoConnect(lv_event_t* event) {
auto* self = static_cast<BtPeerSettings*>(lv_event_get_user_data(event));
bool is_on = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED);
bluetooth::settings::PairedDevice device;
if (bluetooth::settings::load(self->addrHex, device)) {
device.autoConnect = is_on;
if (!bluetooth::settings::save(device)) {
LOG_E(TAG, "Failed to save auto-connect setting");
}
}
}
void requestViewUpdate() const {
if (viewEnabled) {
lvgl_lock();
updateViews();
lvgl_unlock();
}
}
void updateViews() const {
if (isCurrentlyConnected()) {
lv_obj_remove_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(connectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(disconnectButton, LV_STATE_DISABLED);
} else {
lv_obj_add_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(connectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(connectButton, LV_STATE_DISABLED);
}
}
public:
void onCreate(AppContext& app) override {
const auto parameters = app.getParameters();
check(parameters != nullptr, "Parameters missing");
addrHex = parameters->getString("addr");
// Load addr and profileId from stored settings — avoids manual hex parsing
// (std::stoul throws on invalid input and exceptions are disabled).
bluetooth::settings::PairedDevice device;
if (bluetooth::settings::load(addrHex, device)) {
addr = device.addr;
profileId = device.profileId;
}
}
static void onKernelBtEvent(struct Device* /*device*/, void* context, struct BtEvent /*event*/) {
static_cast<BtPeerSettings*>(context)->requestViewUpdate();
}
void onShow(AppContext& app, lv_obj_t* parent) override {
{
Device* dev;
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE) {
bluetooth_add_event_callback(dev, this, onKernelBtEvent);
device_put(dev);
}
}
// Load stored settings (name, autoConnect)
bluetooth::settings::PairedDevice device;
bool deviceLoaded = bluetooth::settings::load(addrHex, device);
std::string title = (deviceLoaded && !device.name.empty()) ? device.name : addrHex;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl_toolbar_create(parent, title.c_str());
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lvgl::obj_set_style_bg_invisible(wrapper);
connectButton = lv_button_create(wrapper);
lv_obj_set_width(connectButton, LV_PCT(100));
lv_obj_add_event_cb(connectButton, onPressConnect, LV_EVENT_SHORT_CLICKED, this);
auto* connect_label = lv_label_create(connectButton);
lv_obj_align(connect_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(connect_label, "Connect");
disconnectButton = lv_button_create(wrapper);
lv_obj_set_width(disconnectButton, LV_PCT(100));
lv_obj_add_event_cb(disconnectButton, onPressDisconnect, LV_EVENT_SHORT_CLICKED, this);
auto* disconnect_label = lv_label_create(disconnectButton);
lv_obj_align(disconnect_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(disconnect_label, "Disconnect");
auto* forget_button = lv_button_create(wrapper);
lv_obj_set_width(forget_button, LV_PCT(100));
lv_obj_add_event_cb(forget_button, onPressForget, LV_EVENT_SHORT_CLICKED, this);
auto* forget_label = lv_label_create(forget_button);
lv_obj_align(forget_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(forget_label, "Forget");
// Auto-connect toggle row
auto* auto_connect_wrapper = lv_obj_create(wrapper);
lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lvgl::obj_set_style_bg_invisible(auto_connect_wrapper);
lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
auto* auto_connect_label = lv_label_create(auto_connect_wrapper);
lv_label_set_text(auto_connect_label, "Auto-connect");
lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper);
lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, this);
lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0);
if (deviceLoaded && device.autoConnect) {
lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(auto_connect_switch, LV_STATE_CHECKED);
}
viewEnabled = true;
updateViews();
}
void onHide(AppContext& app) override {
Device* dev;
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &dev) == ERROR_NONE) {
bluetooth_remove_event_callback(dev, onKernelBtEvent);
device_put(dev);
}
viewEnabled = false;
}
void onResult(AppContext& appContext, LaunchId /*launchId*/, Result result, std::unique_ptr<Bundle> bundle) override {
if (result != Result::Ok || bundle == nullptr) return;
if (alertdialog::getResultIndex(*bundle) != 0) return; // 0 = Yes
// Disconnect first if connected
if (isCurrentlyConnected()) {
if (profileId == BT_PROFILE_HID_HOST) {
bluetooth::hidHostDisconnect();
} else {
bluetooth::disconnect(addr, profileId);
}
}
bluetooth::unpair(addr);
stop();
}
lv_obj_t* connectButton = nullptr;
lv_obj_t* disconnectButton = nullptr;
};
extern const AppManifest manifest = {
.appId = "BtPeerSettings",
.appName = "BT Device Settings",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<BtPeerSettings>
bool isCurrentlyConnected(const Context* ctx) {
for (const auto& p : bluetooth::getPairedPeers()) {
if (p.addr == ctx->addr) return p.connected;
}
return false;
}
void updateViews(const Context* ctx) {
if (isCurrentlyConnected(ctx)) {
lv_obj_remove_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(ctx->connectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(ctx->disconnectButton, LV_STATE_DISABLED);
} else {
lv_obj_add_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->connectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(ctx->connectButton, LV_STATE_DISABLED);
}
}
void onKernelBtEvent(struct Device* /*device*/, void* context, struct BtEvent /*event*/) {
auto* ctx = static_cast<Context*>(context);
lvgl_lock();
updateViews(ctx);
lvgl_unlock();
}
void onPressConnect(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
if (ctx->profileId == BT_PROFILE_HID_HOST) {
bluetooth::hidHostConnect(ctx->addr);
} else {
bluetooth::connect(ctx->addr, ctx->profileId);
}
lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED);
}
void onPressDisconnect(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
if (ctx->profileId == BT_PROFILE_HID_HOST) {
bluetooth::hidHostDisconnect();
} else {
bluetooth::disconnect(ctx->addr, ctx->profileId);
}
lv_obj_add_state(lv_event_get_target_obj(event), LV_STATE_DISABLED);
}
void onPressForget(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Result isn't tracked by launch id (matches the original's behavior) - this app only
// ever has one dialog in flight at a time.
alertdialog::start(ctx->appInstanceId, "Confirmation", "Forget this device?", std::vector<std::string> { "Yes", "No" });
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onToggleAutoConnect(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
bool is_on = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED);
bluetooth::settings::PairedDevice device;
if (bluetooth::settings::load(ctx->addrHex, device)) {
device.autoConnect = is_on;
if (!bluetooth::settings::save(device)) {
LOG_E(TAG, "Failed to save auto-connect setting");
}
}
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
bluetooth::settings::PairedDevice device;
bool deviceLoaded = bluetooth::settings::load(ctx->addrHex, device);
std::string title = (deviceLoaded && !device.name.empty()) ? device.name : ctx->addrHex;
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, title.c_str());
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lvgl::obj_set_style_bg_invisible(wrapper);
ctx->connectButton = lv_button_create(wrapper);
lv_obj_set_width(ctx->connectButton, LV_PCT(100));
lv_obj_add_event_cb(ctx->connectButton, onPressConnect, LV_EVENT_SHORT_CLICKED, ctx);
auto* connect_label = lv_label_create(ctx->connectButton);
lv_obj_align(connect_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(connect_label, "Connect");
ctx->disconnectButton = lv_button_create(wrapper);
lv_obj_set_width(ctx->disconnectButton, LV_PCT(100));
lv_obj_add_event_cb(ctx->disconnectButton, onPressDisconnect, LV_EVENT_SHORT_CLICKED, ctx);
auto* disconnect_label = lv_label_create(ctx->disconnectButton);
lv_obj_align(disconnect_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(disconnect_label, "Disconnect");
auto* forget_button = lv_button_create(wrapper);
lv_obj_set_width(forget_button, LV_PCT(100));
lv_obj_add_event_cb(forget_button, onPressForget, LV_EVENT_SHORT_CLICKED, ctx);
auto* forget_label = lv_label_create(forget_button);
lv_obj_align(forget_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(forget_label, "Forget");
// Auto-connect toggle row
auto* auto_connect_wrapper = lv_obj_create(wrapper);
lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lvgl::obj_set_style_bg_invisible(auto_connect_wrapper);
lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
auto* auto_connect_label = lv_label_create(auto_connect_wrapper);
lv_label_set_text(auto_connect_label, "Auto-connect");
lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper);
lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, ctx);
lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0);
if (deviceLoaded && device.autoConnect) {
lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(auto_connect_switch, LV_STATE_CHECKED);
}
updateViews(ctx);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.addrHex = (argc > 0) ? argv[0] : std::string();
// Load addr and profileId from stored settings - avoids manual hex parsing (std::stoul
// throws on invalid input and exceptions are disabled).
bluetooth::settings::PairedDevice device;
if (bluetooth::settings::load(ctx.addrHex, device)) {
ctx.addr = device.addr;
ctx.profileId = device.profileId;
}
Device* btDevice = nullptr;
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &btDevice) == ERROR_NONE) {
bluetooth_add_event_callback(btDevice, &ctx, onKernelBtEvent);
device_put(btDevice);
}
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
case APP_EVENT_RESULT:
if (event.result.result == 0) { // 0 = Yes
if (isCurrentlyConnected(&ctx)) {
if (ctx.profileId == BT_PROFILE_HID_HOST) {
bluetooth::hidHostDisconnect();
} else {
bluetooth::disconnect(ctx.addr, ctx.profileId);
}
}
bluetooth::unpair(ctx.addr);
app_manager_finish(appInstanceId);
shouldClose = true;
}
app_manager_stop(event.result.launch_id);
break;
default:
break;
}
}
if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &btDevice) == ERROR_NONE) {
bluetooth_remove_event_callback(btDevice, onKernelBtEvent);
device_put(btDevice);
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
void start(const std::string& addrHex) {
const char* argv[] = { addrHex.c_str() };
uint32_t instanceId = 0;
app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId);
}
extern const ::AppManifest manifest = {
.id = "BtPeerSettings",
.name = "BT Device Settings",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace tt::app::btpeersettings
+109 -64
View File
@@ -6,11 +6,15 @@
#include <Tactility/app/chat/ChatAppPrivate.h>
#include <Tactility/app/chat/ChatProtocol.h>
#include <Tactility/app/AppManifest.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h>
#include <algorithm>
@@ -20,56 +24,34 @@
namespace tt::app::chat {
extern const ::AppManifest manifest;
constexpr auto* TAG = "ChatApp";
static constexpr uint8_t BROADCAST_ADDRESS[ESP_NOW_ETH_ALEN] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF };
void ChatApp::enableEspNow() {
void enableEspNow(Context* ctx) {
static uint8_t defaultKey[ESP_NOW_KEY_LEN] = {};
auto config = service::espnow::EspNowConfig(
settings.hasEncryptionKey ? settings.encryptionKey.data() : defaultKey,
ctx->settings.hasEncryptionKey ? ctx->settings.encryptionKey.data() : defaultKey,
service::espnow::Mode::Station,
1, // Channel 1 default; actual channel determined by WiFi if connected
false,
settings.hasEncryptionKey
ctx->settings.hasEncryptionKey
);
service::espnow::enable(config);
}
void ChatApp::disableEspNow() {
void disableEspNow(Context* ctx) {
(void)ctx;
if (service::espnow::isEnabled()) {
service::espnow::disable();
}
}
void ChatApp::onCreate(AppContext& appContext) {
isFirstLaunch = !settingsFileExists();
settings = loadSettings();
state.setLocalNickname(settings.nickname);
if (!settings.chatChannel.empty()) {
state.setCurrentChannel(settings.chatChannel);
}
enableEspNow();
namespace {
receiveSubscription = service::espnow::subscribeReceiver(
[this](const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
onReceive(receiveInfo, data, length);
}
);
}
void ChatApp::onDestroy(AppContext& appContext) {
service::espnow::unsubscribeReceiver(receiveSubscription);
disableEspNow();
}
void ChatApp::onShow(AppContext& context, lv_obj_t* parent) {
view.init(context, parent);
if (isFirstLaunch) {
view.showSettings(settings);
}
}
void ChatApp::onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
void onReceive(Context* ctx, const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
if (length <= 0) return;
ParsedMessage parsed;
@@ -82,21 +64,31 @@ void ChatApp::onReceive(const esp_now_recv_info_t* receiveInfo, const uint8_t* d
msg.target = parsed.target;
msg.isOwn = false;
state.addMessage(msg);
ctx->state.addMessage(msg);
lvgl_lock();
view.displayMessage(msg);
ctx->view.displayMessage(msg);
lvgl_unlock();
}
void ChatApp::sendMessage(const std::string& text) {
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->view.init(parent);
if (ctx->isFirstLaunch) {
ctx->view.showSettings(ctx->settings);
}
}
} // namespace
void sendMessage(Context* ctx, const std::string& text) {
if (text.empty()) return;
std::string nickname = state.getLocalNickname();
std::string channel = state.getCurrentChannel();
std::string nickname = ctx->state.getLocalNickname();
std::string channel = ctx->state.getCurrentChannel();
std::vector<uint8_t> wireMsg;
if (!serializeTextMessage(settings.senderId, BROADCAST_ID, nickname, channel, text, wireMsg)) {
if (!serializeTextMessage(ctx->settings.senderId, BROADCAST_ID, nickname, channel, text, wireMsg)) {
LOG_E(TAG, "Failed to serialize message");
return;
}
@@ -111,18 +103,18 @@ void ChatApp::sendMessage(const std::string& text) {
msg.target = channel;
msg.isOwn = true;
state.addMessage(msg);
ctx->state.addMessage(msg);
lvgl_lock();
view.displayMessage(msg);
ctx->view.displayMessage(msg);
lvgl_unlock();
}
void ChatApp::applySettings(const std::string& nickname, const std::string& keyHex) {
void applySettings(Context* ctx, const std::string& nickname, const std::string& keyHex) {
bool needRestart = false;
// Trim nickname to protocol limit
settings.nickname = nickname.substr(0, MAX_NICKNAME_LEN);
ctx->settings.nickname = nickname.substr(0, MAX_NICKNAME_LEN);
// Parse hex key
if (keyHex.size() == ESP_NOW_KEY_LEN * 2) {
@@ -134,50 +126,103 @@ void ChatApp::applySettings(const std::string& nickname, const std::string& keyH
newKey[i] = static_cast<uint8_t>(strtoul(hex, nullptr, 16));
}
// Restart if key changed OR if encryption is being enabled
bool wasEnabled = settings.hasEncryptionKey;
if (!wasEnabled || !std::equal(newKey, newKey + ESP_NOW_KEY_LEN, settings.encryptionKey.begin())) {
std::copy(newKey, newKey + ESP_NOW_KEY_LEN, settings.encryptionKey.begin());
bool wasEnabled = ctx->settings.hasEncryptionKey;
if (!wasEnabled || !std::equal(newKey, newKey + ESP_NOW_KEY_LEN, ctx->settings.encryptionKey.begin())) {
std::copy(newKey, newKey + ESP_NOW_KEY_LEN, ctx->settings.encryptionKey.begin());
needRestart = true;
}
settings.hasEncryptionKey = true;
ctx->settings.hasEncryptionKey = true;
} else {
LOG_W(TAG, "Invalid hex characters in encryption key");
}
} else if (keyHex.empty()) {
if (settings.hasEncryptionKey) {
settings.encryptionKey.fill(0);
settings.hasEncryptionKey = false;
if (ctx->settings.hasEncryptionKey) {
ctx->settings.encryptionKey.fill(0);
ctx->settings.hasEncryptionKey = false;
needRestart = true;
}
} else {
LOG_W(TAG, "Key must be exactly %d hex characters, got %d", (int)(ESP_NOW_KEY_LEN * 2), (int)keyHex.size());
}
state.setLocalNickname(settings.nickname);
saveSettings(settings);
ctx->state.setLocalNickname(ctx->settings.nickname);
saveSettings(ctx->settings);
if (needRestart) {
disableEspNow();
enableEspNow();
disableEspNow(ctx);
enableEspNow(ctx);
}
}
void ChatApp::switchChannel(const std::string& chatChannel) {
void switchChannel(Context* ctx, const std::string& chatChannel) {
const auto trimmedChannel = chatChannel.substr(0, MAX_TARGET_LEN);
state.setCurrentChannel(trimmedChannel);
settings.chatChannel = trimmedChannel;
saveSettings(settings);
ctx->state.setCurrentChannel(trimmedChannel);
ctx->settings.chatChannel = trimmedChannel;
saveSettings(ctx->settings);
lvgl_lock();
view.refreshMessageList();
ctx->view.refreshMessageList();
lvgl_unlock();
}
extern const AppManifest manifest = {
.appId = "Chat",
.appName = "Chat",
.appIcon = LVGL_ICON_SHARED_FORUM,
.createApp = create<ChatApp>
namespace {
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.isFirstLaunch = !settingsFileExists();
ctx.settings = loadSettings();
ctx.state.setLocalNickname(ctx.settings.nickname);
if (!ctx.settings.chatChannel.empty()) {
ctx.state.setCurrentChannel(ctx.settings.chatChannel);
}
enableEspNow(&ctx);
ctx.receiveSubscription = service::espnow::subscribeReceiver(
[&ctx](const esp_now_recv_info_t* receiveInfo, const uint8_t* data, int length) {
onReceive(&ctx, receiveInfo, data, length);
}
);
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
service::espnow::unsubscribeReceiver(ctx.receiveSubscription);
disableEspNow(&ctx);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "Chat",
.name = "Chat",
.category = APP_CATEGORY_USER,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace tt::app::chat
+1 -1
View File
@@ -7,9 +7,9 @@
#include <Tactility/app/chat/ChatSettings.h>
#include <Tactility/app/chat/ChatProtocol.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/Paths.h>
#include <crypt/crypt.h>
+22 -7
View File
@@ -8,7 +8,9 @@
#include <Tactility/app/chat/ChatAppPrivate.h>
#include <Tactility/app/chat/ChatProtocol.h>
#include <Tactility/lvgl/Toolbar.h>
#include <app/event.h>
#include <lvgl/widgets/toolbar.h>
#include <cstdio>
#include <cstring>
@@ -144,11 +146,23 @@ void ChatView::createChannelPanel(lv_obj_t* parent) {
lv_label_set_text(cancelLbl, "Cancel");
}
void ChatView::init(AppContext& appContext, lv_obj_t* parent) {
void ChatView::onBackPressed(lv_event_t* e) {
auto* self = static_cast<ChatView*>(lv_event_get_user_data(e));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(self->app->appInstanceId, &closeEvent);
}
void ChatView::init(lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
toolbar = lvgl::toolbar_create(parent, appContext);
toolbar = lvgl_toolbar_create(parent, "Chat");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, this);
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_LIST, onChannelClicked, this);
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_SETTINGS, onSettingsClicked, this);
updateToolbarTitle();
@@ -245,14 +259,14 @@ void ChatView::onSendClicked(lv_event_t* e) {
auto* self = static_cast<ChatView*>(lv_event_get_user_data(e));
auto* text = lv_textarea_get_text(self->inputField);
if (text && strlen(text) > 0) {
self->app->sendMessage(std::string(text));
sendMessage(self->app, std::string(text));
lv_textarea_set_text(self->inputField, "");
}
}
void ChatView::onSettingsClicked(lv_event_t* e) {
auto* self = static_cast<ChatView*>(lv_event_get_user_data(e));
self->showSettings(self->app->getSettings());
self->showSettings(self->app->settings);
}
void ChatView::onSettingsSave(lv_event_t* e) {
@@ -262,7 +276,8 @@ void ChatView::onSettingsSave(lv_event_t* e) {
auto* keyHex = lv_textarea_get_text(self->keyInput);
if (nickname && strlen(nickname) > 0) {
self->app->applySettings(
applySettings(
self->app,
std::string(nickname),
keyHex ? std::string(keyHex) : std::string()
);
@@ -284,7 +299,7 @@ void ChatView::onChannelSave(lv_event_t* e) {
auto* self = static_cast<ChatView*>(lv_event_get_user_data(e));
auto* text = lv_textarea_get_text(self->channelInput);
if (text && strlen(text) > 0) {
self->app->switchChannel(std::string(text));
switchChannel(self->app, std::string(text));
}
self->hideChannelSelector();
}
@@ -4,137 +4,208 @@
#include <Tactility/app/crashdiagnostics/QrUrl.h>
#include <Tactility/app/launcher/Launcher.h>
#include <Tactility/lvgl/Statusbar.h>
#include <Tactility/service/loader/Loader.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl.h>
#include <qrcode.h>
#include <tactility/drivers/pointer.h>
#include <tactility/log.h>
#include <memory>
namespace tt::app::crashdiagnostics {
constexpr auto* TAG = "CrashDiagnostics";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
namespace {
struct Context {
uint32_t appInstanceId;
// Set when widget creation hit an unrecoverable error (e.g. the QR code doesn't fit on
// screen) - appMain() skips the event loop and closes immediately without ever starting
// the launcher, matching the old model's stop()-without-launcher-start() error paths.
bool hasFatalError = false;
// Set by onContinuePressed() right before it emits APP_EVENT_CLOSE - read by appMain()
// after its own thread finishes cleanup, to decide whether to start the launcher
// afterwards (matches the old model's onContinuePressed(): stop() then launcher::start()).
bool continuePressed = false;
};
void onContinuePressed(lv_event_t* event) {
stop(manifest.appId);
launcher::start();
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->continuePressed = true;
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself. launcher::start() is deferred to appMain(), after this app's
// own thread has finished cleaning up.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
class CrashDiagnosticsApp : public App {
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
public:
auto* display = lv_obj_get_display(parent);
int32_t parent_height = lv_display_get_vertical_resolution(display) - lvgl::statusbar_get_height();
void onShow(AppContext& app, lv_obj_t* parent) override {
auto* display = lv_obj_get_display(parent);
int32_t parent_height = lv_display_get_vertical_resolution(display) - lvgl::statusbar_get_height();
lv_obj_add_event_cb(parent, onContinuePressed, LV_EVENT_SHORT_CLICKED, ctx);
auto* top_label = lv_label_create(parent);
lv_label_set_text(top_label, "Oops! We've crashed ..."); // TODO: Funny messages
lv_obj_align(top_label, LV_ALIGN_TOP_MID, 0, 2);
lv_obj_add_event_cb(parent, onContinuePressed, LV_EVENT_SHORT_CLICKED, nullptr);
auto* top_label = lv_label_create(parent);
lv_label_set_text(top_label, "Oops! We've crashed ..."); // TODO: Funny messages
lv_obj_align(top_label, LV_ALIGN_TOP_MID, 0, 2);
auto* bottom_label = lv_label_create(parent);
if (device_has_active_by_type(&POINTER_TYPE)) {
lv_label_set_text(bottom_label, "Tap screen to continue");
} else {
lv_label_set_text(bottom_label, "Reboot device to continue");
}
lv_obj_align(bottom_label, LV_ALIGN_BOTTOM_MID, 0, -2);
auto* bottom_label = lv_label_create(parent);
if (device_has_active_by_type(&POINTER_TYPE)) {
lv_label_set_text(bottom_label, "Tap screen to continue");
} else {
lv_label_set_text(bottom_label, "Reboot device to continue");
}
lv_obj_align(bottom_label, LV_ALIGN_BOTTOM_MID, 0, -2);
std::string url = getUrlFromCrashData();
LOG_I(TAG, "%s", url.c_str());
size_t url_length = url.length();
std::string url = getUrlFromCrashData();
LOG_I(TAG, "%s", url.c_str());
size_t url_length = url.length();
int qr_version;
if (!getQrVersionForBinaryDataLength(url_length, qr_version)) {
LOG_E(TAG, "QR is too large");
ctx->hasFatalError = true;
return;
}
int qr_version;
if (!getQrVersionForBinaryDataLength(url_length, qr_version)) {
LOG_E(TAG, "QR is too large");
stop(manifest.appId);
return;
}
LOG_I(TAG, "QR version %d (length: %d)", qr_version, (int)url_length);
auto qrcodeData = std::make_shared<uint8_t[]>(qrcode_getBufferSize(qr_version));
if (qrcodeData == nullptr) {
LOG_E(TAG, "Failed to allocate QR buffer");
ctx->hasFatalError = true;
return;
}
LOG_I(TAG, "QR version %d (length: %d)", qr_version, (int)url_length);
auto qrcodeData = std::make_shared<uint8_t[]>(qrcode_getBufferSize(qr_version));
if (qrcodeData == nullptr) {
LOG_E(TAG, "Failed to allocate QR buffer");
stop(manifest.appId);
return;
}
QRCode qrcode;
LOG_I(TAG, "QR init text");
if (qrcode_initText(&qrcode, qrcodeData.get(), qr_version, ECC_LOW, url.c_str()) != 0) {
LOG_E(TAG, "QR init text failed");
ctx->hasFatalError = true;
return;
}
QRCode qrcode;
LOG_I(TAG, "QR init text");
if (qrcode_initText(&qrcode, qrcodeData.get(), qr_version, ECC_LOW, url.c_str()) != 0) {
LOG_E(TAG, "QR init text failed");
stop(manifest.appId);
return;
}
LOG_I(TAG, "QR size: %d", qrcode.size);
LOG_I(TAG, "QR size: %d", qrcode.size);
// Calculate QR dot size
int32_t top_label_height = lv_obj_get_height(top_label) + 2;
int32_t bottom_label_height = lv_obj_get_height(bottom_label) + 2;
LOG_I(TAG, "Create canvas");
int32_t available_height = parent_height - top_label_height - bottom_label_height;
int32_t available_width = lv_display_get_horizontal_resolution(display);
int32_t smallest_size = std::min(available_height, available_width);
int32_t pixel_size;
if (qrcode.size * 2 <= smallest_size) {
pixel_size = 2;
} else if (qrcode.size <= smallest_size) {
pixel_size = 1;
} else {
LOG_E(TAG, "QR code won't fit screen");
ctx->hasFatalError = true;
return;
}
// Calculate QR dot size
int32_t top_label_height = lv_obj_get_height(top_label) + 2;
int32_t bottom_label_height = lv_obj_get_height(bottom_label) + 2;
LOG_I(TAG, "Create canvas");
int32_t available_height = parent_height - top_label_height - bottom_label_height;
int32_t available_width = lv_display_get_horizontal_resolution(display);
int32_t smallest_size = std::min(available_height, available_width);
int32_t pixel_size;
if (qrcode.size * 2 <= smallest_size) {
pixel_size = 2;
} else if (qrcode.size <= smallest_size) {
pixel_size = 1;
} else {
LOG_E(TAG, "QR code won't fit screen");
stop(manifest.appId);
return;
}
auto* canvas = lv_canvas_create(parent);
lv_obj_set_size(canvas, pixel_size * qrcode.size, pixel_size * qrcode.size);
lv_obj_align(canvas, LV_ALIGN_CENTER, 0, 0);
lv_canvas_fill_bg(canvas, lv_color_black(), LV_OPA_COVER);
lv_obj_set_content_height(canvas, qrcode.size * pixel_size);
lv_obj_set_content_width(canvas, qrcode.size * pixel_size);
auto* canvas = lv_canvas_create(parent);
lv_obj_set_size(canvas, pixel_size * qrcode.size, pixel_size * qrcode.size);
lv_obj_align(canvas, LV_ALIGN_CENTER, 0, 0);
lv_canvas_fill_bg(canvas, lv_color_black(), LV_OPA_COVER);
lv_obj_set_content_height(canvas, qrcode.size * pixel_size);
lv_obj_set_content_width(canvas, qrcode.size * pixel_size);
LOG_I(TAG, "Create draw buffer");
auto* draw_buf = lv_draw_buf_create(pixel_size * qrcode.size, pixel_size * qrcode.size, LV_COLOR_FORMAT_RGB565, LV_STRIDE_AUTO);
if (draw_buf == nullptr) {
LOG_E(TAG, "Failed to allocate draw buffer");
ctx->hasFatalError = true;
return;
}
LOG_I(TAG, "Create draw buffer");
auto* draw_buf = lv_draw_buf_create(pixel_size * qrcode.size, pixel_size * qrcode.size, LV_COLOR_FORMAT_RGB565, LV_STRIDE_AUTO);
if (draw_buf == nullptr) {
LOG_E(TAG, "Failed to allocate draw buffer");
stop(manifest.appId);
return;
}
lv_canvas_set_draw_buf(canvas, draw_buf);
lv_canvas_set_draw_buf(canvas, draw_buf);
for (uint8_t y = 0; y < qrcode.size; y++) {
for (uint8_t x = 0; x < qrcode.size; x++) {
bool colored = qrcode_getModule(&qrcode, x, y);
auto color = colored ? lv_color_white() : lv_color_black();
int32_t pos_x = x * pixel_size;
int32_t pos_y = y * pixel_size;
for (int px = 0; px < pixel_size; px++) {
for (int py = 0; py < pixel_size; py++) {
lv_canvas_set_px(canvas, pos_x + px, pos_y + py, color, LV_OPA_COVER);
}
for (uint8_t y = 0; y < qrcode.size; y++) {
for (uint8_t x = 0; x < qrcode.size; x++) {
bool colored = qrcode_getModule(&qrcode, x, y);
auto color = colored ? lv_color_white() : lv_color_black();
int32_t pos_x = x * pixel_size;
int32_t pos_y = y * pixel_size;
for (int px = 0; px < pixel_size; px++) {
for (int py = 0; py < pixel_size; py++) {
lv_canvas_set_px(canvas, pos_x + px, pos_y + py, color, LV_OPA_COVER);
}
}
}
}
};
}
extern const AppManifest manifest = {
.appId = "CrashDiagnostics",
.appName = "Crash Diagnostics",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<CrashDiagnosticsApp>
};
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
void start() {
app::start(manifest.appId);
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
if (!ctx.hasFatalError) {
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
} else {
app_manager_finish(appInstanceId);
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
bool continuePressed = ctx.continuePressed;
if (continuePressed) {
launcher::start();
}
return 0;
}
} // namespace
#endif
void start() {
uint32_t instanceId = 0;
app_manager_start(manifest.id, &instanceId);
}
extern const ::AppManifest manifest = {
.id = "CrashDiagnostics",
.name = "Crash Diagnostics",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
#endif
+197 -140
View File
@@ -2,179 +2,236 @@
#include <Tactility/Tactility.h>
#include <Tactility/Timer.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/development/DevelopmentService.h>
#include <Tactility/service/development/DevelopmentSettings.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/service/wifi/Wifi.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
#include <lvgl/icons/shared.h>
#include <lvgl.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <cstring>
namespace tt::app::development {
constexpr auto* TAG = "Development";
extern const AppManifest manifest;
class DevelopmentApp final : public App {
extern const ::AppManifest manifest;
namespace {
struct Context {
uint32_t appInstanceId;
lv_obj_t* enableSwitch = nullptr;
lv_obj_t* enableOnBootSwitch = nullptr;
lv_obj_t* statusLabel = nullptr;
std::shared_ptr<service::development::DevelopmentService> service;
std::unique_ptr<Timer> timer;
};
Timer timer = Timer(Timer::Type::Periodic, pdMS_TO_TICKS(1000), [this] {
void updateViewState(Context* ctx);
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onEnableSwitchChanged(lv_event_t* event) {
lv_event_code_t code = lv_event_get_code(event);
auto* widget = static_cast<lv_obj_t*>(lv_event_get_target(event));
if (code == LV_EVENT_VALUE_CHANGED) {
bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED);
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
bool is_changed = is_on != ctx->service->isEnabled();
if (is_changed) {
ctx->service->setEnabled(is_on);
}
}
}
void onEnableOnBootSwitchChanged(lv_event_t* event) {
lv_event_code_t code = lv_event_get_code(event);
auto* widget = static_cast<lv_obj_t*>(lv_event_get_target(event));
if (code == LV_EVENT_VALUE_CHANGED) {
bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED);
bool is_changed = is_on != service::development::shouldEnableOnBoot();
if (is_changed) {
// Dispatch it, so file IO doesn't block the UI
getMainDispatcher().dispatch([is_on] {
service::development::setEnableOnBoot(is_on);
});
}
}
}
void updateViewState(Context* ctx) {
if (!ctx->service->isEnabled()) {
lv_label_set_text(ctx->statusLabel, "Service disabled");
} else if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) {
lv_label_set_text(ctx->statusLabel, "Waiting for connection...");
} else { // enabled and connected to wifi
auto ip = service::wifi::getIp();
if (ip.empty()) {
lv_label_set_text(ctx->statusLabel, "Waiting for IP...");
} else {
const std::string status = std::format("Available at {}", ip);
lv_label_set_text(ctx->statusLabel, status.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);
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Development");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
ctx->enableSwitch = lvgl_toolbar_add_switch_action(toolbar);
lv_obj_add_event_cb(ctx->enableSwitch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, ctx);
if (ctx->service->isEnabled()) {
lv_obj_add_state(ctx->enableSwitch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(ctx->enableSwitch, LV_STATE_CHECKED);
}
// Wrappers
lv_obj_t* content_wrapper = lv_obj_create(parent);
lv_obj_set_width(content_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(content_wrapper, 1);
lv_obj_set_flex_flow(content_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(content_wrapper, 0, LV_STATE_DEFAULT);
lvgl::obj_set_style_bg_invisible(content_wrapper);
// Enable on boot
lv_obj_t* enable_wrapper = lv_obj_create(content_wrapper);
lv_obj_set_size(enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lvgl::obj_set_style_bg_invisible(enable_wrapper);
lv_obj_set_style_border_width(enable_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(enable_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_t* enable_label = lv_label_create(enable_wrapper);
lv_label_set_text(enable_label, "Enable on boot");
lv_obj_align(enable_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->enableOnBootSwitch = lv_switch_create(enable_wrapper);
lv_obj_add_event_cb(ctx->enableOnBootSwitch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, ctx);
lv_obj_align(ctx->enableOnBootSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
if (service::development::shouldEnableOnBoot()) {
lv_obj_add_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(ctx->enableOnBootSwitch, LV_STATE_CHECKED);
}
// Status
ctx->statusLabel = lv_label_create(content_wrapper);
// Warning
auto warning_label = lv_label_create(content_wrapper);
lv_label_set_text(warning_label, "This feature is experimental and uses an unsecured http connection.");
lv_obj_set_width(warning_label, LV_PCT(100));
lv_label_set_long_mode(warning_label, LV_LABEL_LONG_WRAP);
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
lv_obj_set_style_text_color(warning_label, lv_color_make(0xff, 0xff, 0x00), LV_STATE_DEFAULT);
}
updateViewState(ctx);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.service = service::development::findService();
if (ctx.service == nullptr) {
LOG_E(TAG, "Service not found");
// No window/subscription was ever created - matches the old model, where onCreate()
// aborting the app meant onShow() was never called either.
app_manager_finish(appInstanceId);
return 0;
}
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
ctx.timer = std::make_unique<Timer>(Timer::Type::Periodic, pdMS_TO_TICKS(1000), [&ctx, window] {
if (lvgl_is_running()) {
lvgl_lock();
updateViewState();
// Widgets only exist while this window is topmost - skip otherwise. Another app
// (started non-modally, e.g. via app_manager_start()) can bury this window without
// stopping this instance or notifying it; window_manager deletes a buried window's
// widgets, so touching ctx->statusLabel here would use-after-free it.
if (window_manager_get_state(window) == WINDOW_STATE_GRANTED) {
updateViewState(&ctx);
}
lvgl_unlock();
}
});
ctx.timer->start();
static void onEnableSwitchChanged(lv_event_t* event) {
lv_event_code_t code = lv_event_get_code(event);
auto* widget = static_cast<lv_obj_t*>(lv_event_get_target(event));
if (code == LV_EVENT_VALUE_CHANGED) {
bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED);
auto* app = static_cast<DevelopmentApp*>(lv_event_get_user_data(event));
bool is_changed = is_on != app->service->isEnabled();
if (is_changed) {
app->service->setEnabled(is_on);
}
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
static void onEnableOnBootSwitchChanged(lv_event_t* event) {
lv_event_code_t code = lv_event_get_code(event);
auto* widget = static_cast<lv_obj_t*>(lv_event_get_target(event));
if (code == LV_EVENT_VALUE_CHANGED) {
bool is_on = lv_obj_has_state(widget, LV_STATE_CHECKED);
bool is_changed = is_on != service::development::shouldEnableOnBoot();
if (is_changed) {
// Dispatch it, so file IO doesn't block the UI
getMainDispatcher().dispatch([is_on] {
service::development::setEnableOnBoot(is_on);
});
}
}
}
// Equivalent of the old model's onHide(): ensure the periodic update isn't already happening.
lvgl_lock();
ctx.timer->stop();
lvgl_unlock();
void updateViewState() {
if (!service->isEnabled()) {
lv_label_set_text(statusLabel, "Service disabled");
} else if (service::wifi::getRadioState() != service::wifi::RadioState::ConnectionActive) {
lv_label_set_text(statusLabel, "Waiting for connection...");
} else { // enabled and connected to wifi
auto ip = service::wifi::getIp();
if (ip.empty()) {
lv_label_set_text(statusLabel, "Waiting for IP...");
} else {
const std::string status = std::format("Available at {}", ip);
lv_label_set_text(statusLabel, status.c_str());
}
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
public:
void onCreate(AppContext& appContext) override {
service = service::development::findService();
if (service == nullptr) {
LOG_E(TAG, "Service not found");
stop(manifest.appId);
}
}
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lv_obj_t* toolbar = lvgl::toolbar_create(parent, app);
enableSwitch = lvgl_toolbar_add_switch_action(toolbar);
lv_obj_add_event_cb(enableSwitch, onEnableSwitchChanged, LV_EVENT_VALUE_CHANGED, this);
if (service->isEnabled()) {
lv_obj_add_state(enableSwitch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(enableSwitch, LV_STATE_CHECKED);
}
// Wrappers
lv_obj_t* content_wrapper = lv_obj_create(parent);
lv_obj_set_width(content_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(content_wrapper, 1);
lv_obj_set_flex_flow(content_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(content_wrapper, 0, LV_STATE_DEFAULT);
lvgl::obj_set_style_bg_invisible(content_wrapper);
// Enable on boot
lv_obj_t* enable_wrapper = lv_obj_create(content_wrapper);
lv_obj_set_size(enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lvgl::obj_set_style_bg_invisible(enable_wrapper);
lv_obj_set_style_border_width(enable_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(enable_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_t* enable_label = lv_label_create(enable_wrapper);
lv_label_set_text(enable_label, "Enable on boot");
lv_obj_align(enable_label, LV_ALIGN_LEFT_MID, 0, 0);
enableOnBootSwitch = lv_switch_create(enable_wrapper);
lv_obj_add_event_cb(enableOnBootSwitch, onEnableOnBootSwitchChanged, LV_EVENT_VALUE_CHANGED, this);
lv_obj_align(enableOnBootSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
if (service::development::shouldEnableOnBoot()) {
lv_obj_add_state(enableOnBootSwitch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(enableOnBootSwitch, LV_STATE_CHECKED);
}
// Status
statusLabel = lv_label_create(content_wrapper);
// Warning
auto warning_label = lv_label_create(content_wrapper);
lv_label_set_text(warning_label, "This feature is experimental and uses an unsecured http connection.");
lv_obj_set_width(warning_label, LV_PCT(100));
lv_label_set_long_mode(warning_label, LV_LABEL_LONG_WRAP);
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
lv_obj_set_style_text_color(warning_label, lv_color_make(0xff, 0xff, 0x00), LV_STATE_DEFAULT);
}
updateViewState();
timer.start();
}
void onHide(AppContext& appContext) override {
lvgl_lock();
// Ensure that the update isn't already happening
timer.stop();
lvgl_unlock();
}
};
extern const AppManifest manifest = {
.appId = "Development",
.appName = "Development",
.appIcon = LVGL_ICON_SHARED_DEVICES,
.appCategory = Category::Settings,
.createApp = create<DevelopmentApp>
};
void start() {
app::start(manifest.appId);
return 0;
}
} // namespace
#endif // ESP_PLATFORM
extern const ::AppManifest manifest = {
.id = "Development",
.name = "Development",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace
#endif // ESP_PLATFORM
+60 -34
View File
@@ -1,50 +1,76 @@
#include <Tactility/app/files/View.h>
#include <Tactility/app/files/State.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/service/loader/Loader.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <memory>
namespace tt::app::files {
extern const AppManifest manifest;
extern const ::AppManifest manifest;
class FilesApp final : public App {
namespace {
std::unique_ptr<View> view;
std::shared_ptr<State> state;
public:
FilesApp() {
state = std::make_shared<State>();
view = std::make_unique<View>(state);
}
void onShow(AppContext& appContext, lv_obj_t* parent) override {
view->init(appContext, parent);
}
void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) override {
view->onResult(launchId, result, std::move(bundle));
}
void onHide(AppContext& appContext) override {
view->deinit(appContext);
}
struct CreateContext {
View* view;
uint32_t appInstanceId;
};
extern const AppManifest manifest = {
.appId = "Files",
.appName = "Files",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<FilesApp>
};
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<CreateContext*>(userData);
ctx->view->init(ctx->appInstanceId, parent);
}
void start() {
app::start(manifest.appId);
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
auto state = std::make_shared<State>();
View view(state);
CreateContext createContext { &view, appInstanceId };
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &createContext);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
case APP_EVENT_RESULT:
view.onResult(event.result.launch_id, event.result.result);
app_manager_stop(event.result.launch_id);
break;
default:
break;
}
}
view.deinit();
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "Files",
.name = "Files",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
+56 -39
View File
@@ -1,14 +1,19 @@
#include <app/install.h>
#include <app/event.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <Tactility/app/files/SupportedFiles.h>
#include <Tactility/app/files/View.h>
#include <Tactility/Platform.h>
#include <Tactility/StringUtils.h>
#include <Tactility/Tactility.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/app/imageviewer/ImageViewer.h>
#include <Tactility/app/inputdialog/InputDialog.h>
#include <Tactility/app/notes/Notes.h>
#include <Tactility/file/File.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/Platform.h>
#include <Tactility/StringUtils.h>
#include <Tactility/Tactility.h>
#include <tactility/check.h>
#include <tactility/device.h>
@@ -16,17 +21,11 @@
#include <tactility/filesystem/file_mutex.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <unistd.h>
#ifdef ESP_PLATFORM
#include <Tactility/service/loader/Loader.h>
#endif
namespace tt::app::files {
constexpr auto* TAG = "Files";
@@ -38,6 +37,11 @@ static void dirEntryListScrollBeginCallback(lv_event_t* event) {
view->onDirEntryListScrollBegin();
}
static void onBackPressedCallback(lv_event_t* event) {
auto* view = static_cast<files::View*>(lv_event_get_user_data(event));
view->onBackPressed();
}
static void onDirEntryPressedCallback(lv_event_t* event) {
auto* view = static_cast<View*>(lv_event_get_user_data(event));
auto* button = lv_event_get_target_obj(event);
@@ -225,8 +229,8 @@ void View::viewFile(const std::string& path, const std::string& filename) {
// install(filename);
auto message = std::format("Do you want to install {}?", filename);
installAppPath = processed_filepath;
auto choices = std::vector {"Yes", "No"};
installAppLaunchId = alertdialog::start("Install?", message, choices);
auto choices = std::vector<std::string> {"Yes", "No"};
installDialogId = alertdialog::start(appInstanceId, "Install?", message, choices);
#endif
} else if (isSupportedImageFile(filename)) {
imageviewer::start(processed_filepath);
@@ -371,6 +375,15 @@ void View::createDirEntryWidget(lv_obj_t* list, dirent& dir_entry) {
lv_obj_add_event_cb(button, &onDirEntryLongPressedCallback, LV_EVENT_LONG_PRESSED, this);
}
void View::onBackPressed() {
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(appInstanceId, &event);
}
void View::onNavigateUpPressed() {
if (state->getCurrentPath() != "/") {
LOG_I(TAG, "Navigating upwards");
@@ -387,7 +400,7 @@ void View::onRenamePressed() {
std::string entry_name = state->getSelectedChildEntry();
LOG_I(TAG, "Pending rename %s", entry_name.c_str());
state->setPendingAction(State::ActionRename);
inputdialog::start("Rename", "", entry_name);
inputdialog::start(appInstanceId, "Rename", "", entry_name);
}
void View::onDeletePressed() {
@@ -396,19 +409,19 @@ void View::onDeletePressed() {
state->setPendingAction(State::ActionDelete);
std::string message = "Do you want to delete this?\n" + file_path;
const std::vector<std::string> choices = {"Yes", "No"};
alertdialog::start("Are you sure?", message, choices);
alertdialog::start(appInstanceId, "Are you sure?", message, choices);
}
void View::onNewFilePressed() {
LOG_I(TAG, "Creating new file");
state->setPendingAction(State::ActionCreateFile);
inputdialog::start("New File", "Enter filename:", "");
inputdialog::start(appInstanceId, "New File", "Enter filename:", "");
}
void View::onNewFolderPressed() {
LOG_I(TAG, "Creating new folder");
state->setPendingAction(State::ActionCreateFolder);
inputdialog::start("New Folder", "Enter folder name:", "");
inputdialog::start(appInstanceId, "New Folder", "Enter folder name:", "");
}
void View::showActions() {
@@ -445,7 +458,7 @@ void View::onEjectPressed() {
Device* msc_dev = nullptr;
if (device_get_first_active_by_type(&USB_HOST_MSC_TYPE, &msc_dev) != ERROR_NONE || !usb_msc_eject(msc_dev, mount_path.c_str())) {
LOG_W(TAG, "usb_msc_eject: %s not found", mount_path.c_str());
alertdialog::start("Eject failed", "Could not eject \"" + file::getLastPathSegment(mount_path) + "\".");
alertdialog::start(appInstanceId, "Eject failed", "Could not eject \"" + file::getLastPathSegment(mount_path) + "\".");
}
if (msc_dev) {
@@ -528,11 +541,15 @@ void View::update(size_t start_index) {
lvgl_unlock();
}
void View::init(const AppContext& appContext, lv_obj_t* parent) {
void View::init(uint32_t appInstanceId, lv_obj_t* parent) {
this->appInstanceId = appInstanceId;
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, appContext);
auto* toolbar = lvgl_toolbar_create(parent, "Files");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressedCallback, this);
navigate_up_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_UP, &onNavigateUpPressedCallback, this);
new_file_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_FILE, &onNewFilePressedCallback, this);
new_folder_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_DIRECTORY, &onNewFolderPressedCallback, this);
@@ -574,26 +591,23 @@ void View::onNavigate() {
}
}
void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) {
if (result != Result::Ok || bundle == nullptr) {
return;
}
if (
launchId == installAppLaunchId &&
result == Result::Ok &&
alertdialog::getResultIndex(*bundle) == 0
) {
install(installAppPath);
void View::onResult(uint32_t launchId, int32_t result) {
if (launchId == installDialogId && result == 0) {
app_install(installAppPath.c_str());
return;
}
std::string filepath = state->getSelectedChildPath();
LOG_I(TAG, "Result for %s", filepath.c_str());
// Text-entry result (rename/new file/new folder); empty for Cancel, or for a dialog that
// doesn't produce text (delete/paste confirmations) - those switch cases below only look at
// `result`, not this.
std::string resultText = (result == 0) ? inputdialog::getLastText() : std::string();
switch (state->getPendingAction()) {
case State::ActionDelete: {
if (alertdialog::getResultIndex(*bundle) == 0) {
if (result == 0) {
if (file::isDirectory(filepath)) {
if (!file::deleteRecursively(filepath)) {
LOG_W(TAG, "Failed to delete %s", filepath.c_str());
@@ -611,7 +625,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
break;
}
case State::ActionRename: {
auto new_name = inputdialog::getResult(*bundle);
std::string new_name = resultText;
if (!new_name.empty() && new_name != state->getSelectedChildEntry()) {
std::string rename_to = file::getChildPath(state->getCurrentPath(), new_name);
{
@@ -620,7 +634,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
if (stat(rename_to.c_str(), &st) == 0) {
LOG_W(TAG, "Rename: destination already exists: \"%s\"", rename_to.c_str());
state->setPendingAction(State::ActionNone);
alertdialog::start("Rename failed", "\"" + new_name + "\" already exists.");
alertdialog::start(appInstanceId, "Rename failed", "\"" + new_name + "\" already exists.");
break;
}
if (rename(filepath.c_str(), rename_to.c_str()) == 0) {
@@ -636,7 +650,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
break;
}
case State::ActionCreateFile: {
auto filename = inputdialog::getResult(*bundle);
std::string filename = resultText;
if (!filename.empty()) {
std::string new_file_path = file::getChildPath(state->getCurrentPath(), filename);
@@ -664,7 +678,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
break;
}
case State::ActionCreateFolder: {
auto foldername = inputdialog::getResult(*bundle);
std::string foldername = resultText;
if (!foldername.empty()) {
std::string new_folder_path = file::getChildPath(state->getCurrentPath(), foldername);
@@ -690,7 +704,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
break;
}
case State::ActionPaste: {
if (alertdialog::getResultIndex(*bundle) == 0) {
if (result == 0) {
auto clipboard = state->getClipboard();
if (clipboard.has_value()) {
std::string dst = state->getPendingPasteDst();
@@ -712,6 +726,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
LOG_W(TAG, "Overwrite: destination \"%s\" changed since confirmation, aborting", dst.c_str());
state->setPendingAction(State::ActionNone);
alertdialog::start(
appInstanceId,
"Overwrite aborted",
"\"" + file::getLastPathSegment(dst) + "\" changed while the dialog was open. Please try again."
);
@@ -729,6 +744,7 @@ void View::onResult(LaunchId launchId, Result result, std::unique_ptr<Bundle> bu
LOG_E(TAG, "Overwrite: failed to remove existing destination: \"%s\"", dst.c_str());
state->setPendingAction(State::ActionNone);
alertdialog::start(
appInstanceId,
"Overwrite failed",
"Could not remove \"" + file::getLastPathSegment(dst) + "\" before overwriting."
);
@@ -793,7 +809,7 @@ void View::onPastePressed() {
state->setPendingPasteDstStat(dst_stat);
state->setPendingAction(State::ActionPaste);
const std::vector<std::string> choices = {"Overwrite", "Cancel"};
alertdialog::start("File exists", "Overwrite \"" + entry_name + "\"?", choices);
alertdialog::start(appInstanceId, "File exists", "Overwrite \"" + entry_name + "\"?", choices);
return;
}
@@ -834,11 +850,12 @@ void View::doPaste(const std::string& src, bool is_cut, const std::string& dst)
}
} else if (src_delete_failed) {
state->setPendingAction(State::ActionNone); // prevent re-trigger on dialog dismiss
alertdialog::start("Move incomplete", "\"" + filename + "\" was copied but the original could not be removed.\nPlease delete it manually.");
alertdialog::start(appInstanceId, "Move incomplete", "\"" + filename + "\" was copied but the original could not be removed.\nPlease delete it manually.");
} else {
LOG_E(TAG, "Failed to %s \"%s\" to \"%s\"", is_cut ? "move" : "copy", src.c_str(), dst.c_str());
state->setPendingAction(State::ActionNone); // prevent re-trigger on dialog dismiss
alertdialog::start(
appInstanceId,
std::string("Failed to ") + (is_cut ? "move" : "copy"),
"\"" + filename + "\" could not be " + (is_cut ? "moved." : "copied.")
);
@@ -848,7 +865,7 @@ void View::doPaste(const std::string& src, bool is_cut, const std::string& dst)
update();
}
void View::deinit(const AppContext& appContext) {
void View::deinit() {
lv_obj_remove_event_cb(dir_entry_list, dirEntryListScrollBeginCallback);
}
@@ -1,78 +1,116 @@
#include "Tactility/app/fileselection/FileSelectionPrivate.h"
#include "Tactility/app/fileselection/View.h"
#include "Tactility/app/fileselection/State.h"
#include "Tactility/app/AppContext.h"
#include <Tactility/Assets.h>
#include <Tactility/service/loader/Loader.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <memory>
#include <string>
namespace tt::app::fileselection {
extern const ::AppManifest manifest;
constexpr auto* TAG = "FileSelection";
extern const AppManifest manifest;
namespace {
std::string getResultPath(const Bundle& bundle) {
std::string result;
if (bundle.optString("path", result)) {
return result;
} else {
return "";
}
}
Mode getMode(const Bundle& bundle) {
int32_t mode = static_cast<int32_t>(Mode::ExistingOrNew);
bundle.optInt32("mode", mode);
return static_cast<Mode>(mode);
}
void setMode(Bundle& bundle, Mode mode) {
auto mode_int = static_cast<int32_t>(mode);
bundle.putInt32("mode", mode_int);
}
class FileSelection : public App {
std::unique_ptr<View> view;
struct Context {
uint32_t appInstanceId;
Mode mode;
std::shared_ptr<State> state;
public:
FileSelection() {
state = std::make_shared<State>();
view = std::make_unique<View>(state, [this](const std::string& path) {
auto bundle = std::make_unique<Bundle>();
bundle->putString("path", path);
setResult(Result::Ok, std::move(bundle));
stop(manifest.appId);
});
}
void onShow(AppContext& appContext, lv_obj_t* parent) override {
auto mode = getMode(*appContext.getParameters());
view->init(parent, mode);
}
std::unique_ptr<View> view;
// The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this
// is a plain (non-atomic) field safely shared between the LVGL thread (writer, before
// emitting APP_EVENT_CLOSE) and this app's own thread (reader, after waking from it).
int32_t result = 1; // Cancelled - safety-net default if closed without picking a file
};
extern const AppManifest manifest = {
.appId = "FileSelection",
.appName = "File Selection",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<FileSelection>
};
LaunchId startForExistingFile() {
auto bundle = std::make_shared<Bundle>();
setMode(*bundle, Mode::Existing);
return start(manifest.appId, bundle);
// The last picked path. Static rather than per-instance: simple, and in practice only one
// FileSelection dialog is ever open at a time. Written on the LVGL thread (View's select-button
// callback, before emitting APP_EVENT_CLOSE); read by the parent via getLastPath() after
// receiving that event - safe without a lock for the same reason Context::result is (see
// AlertDialog.cpp).
std::string lastPath;
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->view->init(parent, ctx->mode);
}
LaunchId startForExistingOrNewFile() {
auto bundle = std::make_shared<Bundle>();
setMode(*bundle, Mode::ExistingOrNew);
return start(manifest.appId, bundle);
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
// argv layout: [0]="existing" or "existing_or_new".
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.mode = (argc > 0 && std::string(argv[0]) == "existing_or_new") ? Mode::ExistingOrNew : Mode::Existing;
ctx.state = std::make_shared<State>();
ctx.view = std::make_unique<View>(appInstanceId, ctx.state, [&ctx, appInstanceId](const std::string& path) {
// Runs on the LVGL task (View::onSelectButtonPressed) - must NOT call app_manager_stop()
// here: that bound-waits (thread_join) for this app's own thread to finish, which needs
// the LVGL lock (window_manager_remove()) - but this callback runs ON the LVGL task,
// which would deadlock against itself. The caller reaps this instance via
// app_manager_stop() after it receives the APP_EVENT_RESULT instead.
lastPath = path;
ctx.result = 0;
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(appInstanceId, &closeEvent);
});
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
while (true) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
if (event.type == APP_EVENT_CLOSE) {
app_manager_finish(appInstanceId); // no-op: modal children never supersede anything
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return ctx.result;
}
} // namespace
std::string getLastPath() {
return lastPath;
}
uint32_t startForExistingFile(uint32_t callerAppInstanceId) {
const char* argv[] = { "existing" };
uint32_t instanceId = 0;
app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId);
return instanceId;
}
uint32_t startForExistingOrNewFile(uint32_t callerAppInstanceId) {
const char* argv[] = { "existing_or_new" };
uint32_t instanceId = 0;
app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId);
return instanceId;
}
extern const ::AppManifest manifest = {
.id = "FileSelection",
.name = "File Selection",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
+14 -1
View File
@@ -5,6 +5,8 @@
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/file/File.h>
#include <app/event.h>
#include <tactility/check.h>
#include <tactility/log.h>
@@ -15,7 +17,6 @@
#include <unistd.h>
#ifdef ESP_PLATFORM
#include <Tactility/service/loader/Loader.h>
#endif
namespace tt::app::fileselection {
@@ -38,6 +39,16 @@ static void onNavigateUpPressedCallback(lv_event_t* event) {
// endregion
void View::onBackPressedCallback(lv_event_t* event) {
auto* view = static_cast<View*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(view->appInstanceId, &closeEvent);
}
void View::onTapFile(const std::string& path, const std::string& filename) {
std::string file_path = path + "/" + filename;
@@ -183,6 +194,8 @@ void View::init(lv_obj_t* parent, Mode mode) {
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
auto* toolbar = lvgl_toolbar_create(parent, "Select File");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, &onBackPressedCallback, this);
navigate_up_button = lvgl_toolbar_add_image_button_action(toolbar, LV_SYMBOL_UP, &onNavigateUpPressedCallback, this);
auto* wrapper = lv_obj_create(parent);
+274 -251
View File
@@ -1,11 +1,16 @@
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <Tactility/Tactility.h>
#include <Tactility/Timer.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/lvgl/Toolbar.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/device.h>
#include <tactility/time.h>
@@ -20,287 +25,305 @@
#include <gps/gps_settings.h>
namespace tt::app::addgps {
extern AppManifest manifest;
extern const ::AppManifest manifest;
}
namespace tt::app::gpssettings {
extern const AppManifest manifest;
extern const ::AppManifest manifest;
class GpsSettingsApp final : public App {
namespace {
struct DeviceRow {
Device* device;
lv_obj_t* button;
lv_obj_t* buttonLabel;
bool hasConfiguration = false;
size_t configurationIndex = 0;
};
struct DeviceRow {
Device* device;
lv_obj_t* button;
lv_obj_t* buttonLabel;
bool hasConfiguration = false;
size_t configurationIndex = 0;
};
std::unique_ptr<Timer> timer;
struct Context {
uint32_t appInstanceId;
lv_obj_t* deviceListWrapper = nullptr;
std::vector<DeviceRow> deviceRows;
std::atomic<bool> isShown = false;
std::unique_ptr<Timer> timer;
// Set when a delete confirmation is pending; read/cleared on this app's own thread when
// the dialog's result arrives.
bool hasPendingDelete = false;
Device* pendingDeleteDevice = nullptr;
size_t pendingDeleteIndex = 0;
};
static void onAddGpsCallback(lv_event_t* event) {
auto* app = (GpsSettingsApp*)lv_event_get_user_data(event);
app->onAddGps();
}
void onAddGps() {
app::start(addgps::manifest.appId);
}
void rebuildDeviceList(Context* ctx);
void updateDeviceStates(Context* ctx);
void createWidgets(lv_obj_t* parent, void* userData);
static void onDeviceButtonCallback(lv_event_t* event) {
auto* button = lv_event_get_target_obj(event);
auto* device = static_cast<Device*>(lv_obj_get_user_data(button));
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
bool running = device_is_ready(device);
// device_start()/device_stop() are potentially blocking calls, so use a dispatcher to not block the UI
getMainDispatcher().dispatch([device, running] {
if (running) {
device_stop(device);
} else {
device_start(device);
}
});
}
void onAddGpsPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Fire-and-forget top-level launch, matching the original (its result never fed back into
// this app; rebuildDeviceList() runs fresh whenever this app is resumed regardless).
(void)ctx;
uint32_t instanceId = 0;
app_manager_start(addgps::manifest.id, &instanceId);
}
// Finds the persisted configuration backing `device` (matched by its parent UART's name)
// and returns its index into gps_settings_for_each_configuration()'s ordering - the handle
// gps_settings_remove_configuration_at() needs to delete exactly this entry, even if another
// entry happens to have identical field values.
// Devicetree-declared GPS_TYPE devices have no such configuration and never match.
static bool findConfigurationIndexForDevice(Device* device, size_t& outIndex) {
auto* parent = device_get_parent(device);
if (parent == nullptr) {
return false;
}
void onDeviceButtonPressed(lv_event_t* event) {
auto* button = lv_event_get_target_obj(event);
auto* device = static_cast<Device*>(lv_obj_get_user_data(button));
struct Context {
const char* uartName;
size_t* outIndex;
bool found;
} context = { parent->name, &outIndex, false };
gps_settings_for_each_configuration(&context, [](const GpsConfiguration* configuration, size_t index, void* untyped_context) {
auto* ctx = static_cast<Context*>(untyped_context);
if (!ctx->found && strcmp(configuration->uart_name, ctx->uartName) == 0) {
*ctx->outIndex = index;
ctx->found = true;
}
});
return context.found;
}
static void onDeleteButtonCallback(lv_event_t* event) {
auto* app = static_cast<GpsSettingsApp*>(lv_event_get_user_data(event));
auto* button = lv_event_get_target_obj(event);
auto* device = static_cast<Device*>(lv_obj_get_user_data(button));
app->onDeleteDevice(device);
}
void onDeleteDevice(Device* device) {
for (auto& row : deviceRows) {
if (row.device == device && row.hasConfiguration) {
pendingDeleteDevice = device;
pendingDeleteIndex = row.configurationIndex;
hasPendingDelete = true;
alertdialog::start("Confirmation", std::string("Do you want to delete ") + device->name + "?", std::vector<std::string> { "Yes", "No" });
return;
}
}
}
void createDeviceRow(Device* device) {
auto* wrapper = lv_obj_create(deviceListWrapper);
lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(wrapper, 0, 0);
lv_obj_set_style_pad_all(wrapper, 0, 0);
auto* name_label = lv_label_create(wrapper);
char model_name[64];
if (gps_get_model_name(device, model_name, sizeof(model_name)) == ERROR_NONE) {
lv_label_set_text(name_label, model_name);
bool running = device_is_ready(device);
// device_start()/device_stop() are potentially blocking calls, so use a dispatcher to not block the UI
getMainDispatcher().dispatch([device, running] {
if (running) {
device_stop(device);
} else {
lv_label_set_text(name_label, device->name);
device_start(device);
}
});
}
auto* actions_wrapper = lv_obj_create(wrapper);
lv_obj_set_size(actions_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(actions_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_style_border_width(actions_wrapper, 0, 0);
lv_obj_set_style_pad_all(actions_wrapper, 0, 0);
lv_obj_set_style_pad_column(actions_wrapper, 4, 0);
auto* button = lv_button_create(actions_wrapper);
lv_obj_add_event_cb(button, onDeviceButtonCallback, LV_EVENT_SHORT_CLICKED, this);
lv_obj_set_user_data(button, device);
auto* button_label = lv_label_create(button);
lv_label_set_text(button_label, "Start");
DeviceRow row { .device = device, .button = button, .buttonLabel = button_label };
// Only devices backed by a persisted configuration (not devicetree-declared ones) can be deleted.
size_t configurationIndex;
if ((device->flags & DEVICE_FLAG_DYNAMIC) && findConfigurationIndexForDevice(device, configurationIndex)) {
auto* delete_button = lv_button_create(actions_wrapper);
lv_obj_add_event_cb(delete_button, onDeleteButtonCallback, LV_EVENT_SHORT_CLICKED, this);
lv_obj_set_user_data(delete_button, device);
auto* delete_label = lv_label_create(delete_button);
lv_label_set_text(delete_label, LVGL_ICON_SHARED_DELETE);
row.hasConfiguration = true;
row.configurationIndex = configurationIndex;
}
deviceRows.push_back(row);
// Finds the persisted configuration backing `device` (matched by its parent UART's name)
// and returns its index into gps_settings_for_each_configuration()'s ordering - the handle
// gps_settings_remove_configuration_at() needs to delete exactly this entry, even if another
// entry happens to have identical field values.
// Devicetree-declared GPS_TYPE devices have no such configuration and never match.
bool findConfigurationIndexForDevice(Device* device, size_t& outIndex) {
auto* parent = device_get_parent(device);
if (parent == nullptr) {
return false;
}
// Rebuilds the device list. Only needs to run when the set of devices could've changed
// (on show, and after returning from AddGpsApp) - button state itself is refreshed by the timer.
void rebuildDeviceList() {
lv_obj_clean(deviceListWrapper);
deviceRows.clear();
struct FindContext {
const char* uartName;
size_t* outIndex;
bool found;
} findContext = { parent->name, &outIndex, false };
device_for_each_of_type(&GPS_TYPE, this, [](Device* device, void* context) {
static_cast<GpsSettingsApp*>(context)->createDeviceRow(device);
return true;
});
gps_settings_for_each_configuration(&findContext, [](const GpsConfiguration* configuration, size_t index, void* untyped_context) {
auto* ctx = static_cast<FindContext*>(untyped_context);
if (!ctx->found && strcmp(configuration->uart_name, ctx->uartName) == 0) {
*ctx->outIndex = index;
ctx->found = true;
}
});
return findContext.found;
}
void onDeleteButtonPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* button = lv_event_get_target_obj(event);
auto* device = static_cast<Device*>(lv_obj_get_user_data(button));
for (auto& row : ctx->deviceRows) {
if (row.device == device && row.hasConfiguration) {
ctx->pendingDeleteDevice = device;
ctx->pendingDeleteIndex = row.configurationIndex;
ctx->hasPendingDelete = true;
alertdialog::start(ctx->appInstanceId, "Confirmation", std::string("Do you want to delete ") + device->name + "?", std::vector<std::string> { "Yes", "No" });
return;
}
}
}
void createDeviceRow(Context* ctx, Device* device) {
auto* wrapper = lv_obj_create(ctx->deviceListWrapper);
lv_obj_set_size(wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(wrapper, 0, 0);
lv_obj_set_style_pad_all(wrapper, 0, 0);
auto* name_label = lv_label_create(wrapper);
char model_name[64];
if (gps_get_model_name(device, model_name, sizeof(model_name)) == ERROR_NONE) {
lv_label_set_text(name_label, model_name);
} else {
lv_label_set_text(name_label, device->name);
}
void updateDeviceStates() {
lvgl_lock();
for (const auto& row : deviceRows) {
const char* text = "Start";
bool enabled = true;
auto* actions_wrapper = lv_obj_create(wrapper);
lv_obj_set_size(actions_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(actions_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_style_border_width(actions_wrapper, 0, 0);
lv_obj_set_style_pad_all(actions_wrapper, 0, 0);
lv_obj_set_style_pad_column(actions_wrapper, 4, 0);
if (device_is_ready(row.device)) {
switch (gps_get_state(row.device)) {
case GPS_STATE_PENDING_ON:
text = "Starting...";
enabled = false;
break;
case GPS_STATE_PENDING_OFF:
text = "Stopping...";
enabled = false;
break;
default:
text = "Stop";
enabled = true;
break;
auto* button = lv_button_create(actions_wrapper);
lv_obj_add_event_cb(button, onDeviceButtonPressed, LV_EVENT_SHORT_CLICKED, ctx);
lv_obj_set_user_data(button, device);
auto* button_label = lv_label_create(button);
lv_label_set_text(button_label, "Start");
DeviceRow row { .device = device, .button = button, .buttonLabel = button_label };
// Only devices backed by a persisted configuration (not devicetree-declared ones) can be deleted.
size_t configurationIndex;
if ((device->flags & DEVICE_FLAG_DYNAMIC) && findConfigurationIndexForDevice(device, configurationIndex)) {
auto* delete_button = lv_button_create(actions_wrapper);
lv_obj_add_event_cb(delete_button, onDeleteButtonPressed, LV_EVENT_SHORT_CLICKED, ctx);
lv_obj_set_user_data(delete_button, device);
auto* delete_label = lv_label_create(delete_button);
lv_label_set_text(delete_label, LVGL_ICON_SHARED_DELETE);
row.hasConfiguration = true;
row.configurationIndex = configurationIndex;
}
ctx->deviceRows.push_back(row);
}
// Rebuilds the device list. Only needs to run when the set of devices could've changed (on
// creation, and after returning from AddGps) - button state itself is refreshed by the timer.
void rebuildDeviceList(Context* ctx) {
lv_obj_clean(ctx->deviceListWrapper);
ctx->deviceRows.clear();
device_for_each_of_type(&GPS_TYPE, ctx, [](Device* device, void* context) {
createDeviceRow(static_cast<Context*>(context), device);
return true;
});
}
void updateDeviceStates(Context* ctx) {
lvgl_lock();
for (const auto& row : ctx->deviceRows) {
const char* text = "Start";
bool enabled = true;
if (device_is_ready(row.device)) {
switch (gps_get_state(row.device)) {
case GPS_STATE_PENDING_ON:
text = "Starting...";
enabled = false;
break;
case GPS_STATE_PENDING_OFF:
text = "Stopping...";
enabled = false;
break;
default:
text = "Stop";
enabled = true;
break;
}
}
lv_label_set_text(row.buttonLabel, text);
if (enabled) {
lv_obj_remove_state(row.button, LV_STATE_DISABLED);
} else {
lv_obj_add_state(row.button, LV_STATE_DISABLED);
}
}
lvgl_unlock();
}
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);
uint8_t margin = (lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT) ? 2 : 8;
auto* toolbar = lvgl_toolbar_create(parent, "GPS");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddGpsPressed, ctx);
lv_obj_set_style_margin_bottom(toolbar, margin, LV_STATE_DEFAULT);
ctx->deviceListWrapper = lv_obj_create(parent);
lv_obj_set_size(ctx->deviceListWrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(ctx->deviceListWrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_grow(ctx->deviceListWrapper, 1);
lv_obj_set_style_border_width(ctx->deviceListWrapper, 0, 0);
lv_obj_set_style_pad_hor(ctx->deviceListWrapper, margin, 0);
lv_obj_set_style_pad_top(ctx->deviceListWrapper, 0, 0);
lv_obj_set_style_pad_bottom(ctx->deviceListWrapper, margin, 0);
lv_obj_set_style_pad_row(ctx->deviceListWrapper, margin, 0);
rebuildDeviceList(ctx);
updateDeviceStates(ctx);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
// Runs for this app instance's whole lifetime - there's no push notification for GPS
// device state changes, so this is the only way this screen finds out about them.
ctx.timer = std::make_unique<Timer>(Timer::Type::Periodic, seconds_to_ticks(1), [&ctx] {
updateDeviceStates(&ctx);
});
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
ctx.timer->start();
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
case APP_EVENT_RESULT:
if (ctx.hasPendingDelete) {
ctx.hasPendingDelete = false;
if (event.result.result == 0) { // 0 = Yes
lvgl_lock();
std::erase_if(ctx.deviceRows, [&ctx](const DeviceRow& row) {
return row.device == ctx.pendingDeleteDevice;
});
lvgl_unlock();
gps_settings_remove_configuration_at(ctx.pendingDeleteIndex);
ctx.pendingDeleteDevice = nullptr;
lvgl_lock();
rebuildDeviceList(&ctx);
lvgl_unlock();
}
}
}
lv_label_set_text(row.buttonLabel, text);
if (enabled) {
lv_obj_remove_state(row.button, LV_STATE_DISABLED);
} else {
lv_obj_add_state(row.button, LV_STATE_DISABLED);
}
app_manager_stop(event.result.launch_id);
break;
default:
break;
}
lvgl_unlock();
}
public:
ctx.timer->stop();
window_manager_remove(window);
app_event_unsubscribe(&sub);
GpsSettingsApp() {
// Runs while the screen is shown - there's no push notification for GPS device state
// changes, so this is the only way this screen finds out about them.
timer = std::make_unique<Timer>(Timer::Type::Periodic, seconds_to_ticks(1), [this] {
updateDeviceStates();
});
}
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
uint8_t margin = (lvgl_get_ui_density() == LVGL_UI_DENSITY_COMPACT) ? 2 : 8;
auto* toolbar = lvgl::toolbar_create(parent, app);
lvgl_toolbar_add_text_button_action(toolbar, LV_SYMBOL_PLUS, onAddGpsCallback, this);
lv_obj_set_style_margin_bottom(toolbar, margin, LV_STATE_DEFAULT);
deviceListWrapper = lv_obj_create(parent);
lv_obj_set_size(deviceListWrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(deviceListWrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_grow(deviceListWrapper, 1);
lv_obj_set_style_border_width(deviceListWrapper, 0, 0);
lv_obj_set_style_pad_hor(deviceListWrapper, margin, 0);
lv_obj_set_style_pad_top(deviceListWrapper, 0, 0);
lv_obj_set_style_pad_bottom(deviceListWrapper, margin, 0);
lv_obj_set_style_pad_row(deviceListWrapper, margin, 0);
rebuildDeviceList();
timer->start();
updateDeviceStates();
// Only after deviceListWrapper is fully built: onResult() (Loader thread) checks
// this before touching it, since it can run before or after this onShow() call.
isShown = true;
}
void onHide(AppContext& app) override {
isShown = false;
timer->stop();
}
void onResult(AppContext&, LaunchId, Result result, std::unique_ptr<Bundle> bundle) override {
if (!hasPendingDelete) {
return;
}
hasPendingDelete = false;
if (result != Result::Ok || bundle == nullptr || alertdialog::getResultIndex(*bundle) != 0) { // 0 = Yes
return;
}
// This runs on the Loader thread, concurrently with the periodic timer callback
// (updateDeviceStates(), timer daemon thread) and possibly with onShow() (GUI
// thread). Take the same lock updateDeviceStates() uses and hold it across the
// free below, so the timer can never observe pendingDeleteDevice as a dangling
// pointer in deviceRows.
lvgl_lock();
// Drop the stale row unconditionally (cheap vector op, no LVGL calls) - this is
// what keeps the timer safe regardless of whether onShow() has run yet this cycle.
std::erase_if(deviceRows, [this](const DeviceRow& row) {
return row.device == pendingDeleteDevice;
});
lvgl_unlock();
// gps_settings_remove_configuration_at() frees the underlying Device synchronously -
// do this only after the dangling pointer is already out of deviceRows.
gps_settings_remove_configuration_at(pendingDeleteIndex);
pendingDeleteDevice = nullptr;
// Only safe to touch deviceListWrapper if onShow() already built it for this show
// cycle - it may not have run yet, in which case it'll rebuild fresh (post-deletion,
// deviceRows already correct) when it does.
lvgl_lock();
if (isShown) {
rebuildDeviceList();
}
lvgl_unlock();
}
};
extern const AppManifest manifest = {
.appId = "GpsSettings",
.appName = "GPS",
.appIcon = LVGL_ICON_SHARED_NAVIGATION,
.appCategory = Category::Settings,
.createApp = create<GpsSettingsApp>
};
void start() {
app::start(manifest.appId);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "GpsSettings",
.name = "GPS",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace
@@ -2,78 +2,132 @@
#include <lvgl.h>
#include <lvgl/icons/shared.h>
#include <tactility/device.h>
#include <tactility/drivers/grove.h>
#include <Tactility/Tactility.h>
#include <Tactility/lvgl/Toolbar.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/widgets/toolbar.h>
namespace tt::app::grovesettings {
class GroveSettingsApp final : public App {
extern const ::AppManifest manifest;
namespace {
struct Context {
uint32_t appInstanceId;
std::vector<::Device*> devices;
void collectDevices() {
devices.clear();
device_for_each_of_type(&GROVE_TYPE, &devices, [](auto* device, auto* context) {
auto* vec = static_cast<std::vector<::Device*>*>(context);
vec->push_back(device);
return true;
});
}
static void onModeChanged(lv_event_t* e) {
auto* device = static_cast<::Device*>(lv_event_get_user_data(e));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e));
auto mode = static_cast<GroveMode>(lv_dropdown_get_selected(dropdown));
grove_set_mode(device, mode);
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
collectDevices();
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
for (auto* device : devices) {
auto* row = lv_obj_create(main_wrapper);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
auto* label = lv_label_create(row);
lv_label_set_text(label, device->name);
lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0);
auto* dropdown = lv_dropdown_create(row);
lv_dropdown_set_options(dropdown, "Disabled\nUART\nI2C");
lv_obj_align(dropdown, LV_ALIGN_RIGHT_MID, 0, 0);
GroveMode current = GROVE_MODE_DISABLED;
grove_get_mode(device, &current);
lv_dropdown_set_selected(dropdown, static_cast<uint32_t>(current));
lv_obj_add_event_cb(dropdown, onModeChanged, LV_EVENT_VALUE_CHANGED, device);
}
}
};
extern const AppManifest manifest = {
.appId = "GroveSettings",
.appName = "Grove",
.appIcon = LVGL_ICON_SHARED_CABLE,
.appCategory = Category::Settings,
.createApp = create<GroveSettingsApp>
void collectDevices(Context* ctx) {
ctx->devices.clear();
device_for_each_of_type(&GROVE_TYPE, &ctx->devices, [](auto* device, auto* context) {
auto* vec = static_cast<std::vector<::Device*>*>(context);
vec->push_back(device);
return true;
});
}
void onModeChanged(lv_event_t* e) {
auto* device = static_cast<::Device*>(lv_event_get_user_data(e));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e));
auto mode = static_cast<GroveMode>(lv_dropdown_get_selected(dropdown));
grove_set_mode(device, mode);
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
collectDevices(ctx);
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, "Grove");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
for (auto* device : ctx->devices) {
auto* row = lv_obj_create(main_wrapper);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT);
auto* label = lv_label_create(row);
lv_label_set_text(label, device->name);
lv_obj_align(label, LV_ALIGN_LEFT_MID, 0, 0);
auto* dropdown = lv_dropdown_create(row);
lv_dropdown_set_options(dropdown, "Disabled\nUART\nI2C");
lv_obj_align(dropdown, LV_ALIGN_RIGHT_MID, 0, 0);
GroveMode current = GROVE_MODE_DISABLED;
grove_get_mode(device, &current);
lv_dropdown_set_selected(dropdown, static_cast<uint32_t>(current));
lv_obj_add_event_cb(dropdown, onModeChanged, LV_EVENT_VALUE_CHANGED, device);
}
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "GroveSettings",
.name = "Grove",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
}
+325 -291
View File
@@ -1,31 +1,41 @@
#include <Tactility/app/i2cscanner/I2cHelpers.h>
#include <Tactility/app/i2cscanner/I2cScannerPrivate.h>
#include <Tactility/LogMessages.h>
#include <Tactility/Preferences.h>
#include <Tactility/RecursiveMutex.h>
#include <Tactility/Timer.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/loader/Loader.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/drivers/i2c_controller.h>
#include <tactility/log.h>
#include <tactility/paths.h>
#include <tactility/preferences.h>
#include <cassert>
#include <format>
#include <string>
#include <vector>
#include <lvgl/lvgl.h>
#include <lvgl/icons/shared.h>
#include <lvgl/widgets/toolbar.h>
namespace tt::app::i2cscanner {
extern const AppManifest manifest;
extern const ::AppManifest manifest;
class I2cScannerApp final : public App {
namespace {
static constexpr auto* TAG = "I2cScanner";
constexpr auto* TAG = "I2cScanner";
static constexpr auto* START_SCAN_TEXT = "Scan";
static constexpr auto* STOP_SCAN_TEXT = "Stop scan";
constexpr auto* START_SCAN_TEXT = "Scan";
constexpr auto* STOP_SCAN_TEXT = "Stop scan";
struct Context {
uint32_t appInstanceId;
// Core
RecursiveMutex mutex;
@@ -38,68 +48,275 @@ class I2cScannerApp final : public App {
lv_obj_t* scanButtonLabelWidget = nullptr;
lv_obj_t* portDropdownWidget = nullptr;
lv_obj_t* scanListWidget = nullptr;
static void setLastBusIndex(int32_t index);
static int32_t getLastBusIndex();
void selectBus(int32_t selected);
static void onSelectBusCallback(lv_event_t* event);
static void onPressScanCallback(lv_event_t* event);
void onSelectBus(lv_event_t* event);
void onPressScan(lv_event_t* event);
void onScanTimer();
bool shouldStopScanTimer();
bool getPort(struct Device** outPort);
bool addAddressToList(uint8_t address);
bool hasScanThread();
void startScanning();
void stopScanning();
void updateViews();
void updateViewsSafely();
void onScanTimerFinished();
public:
void onShow(AppContext& app, lv_obj_t* parent) override;
void onHide(AppContext& app) override;
};
/** Returns the app data if the app is active. Note that this could clash if the same app is started twice and a background thread is slow. */
std::shared_ptr<I2cScannerApp> optApp() {
auto appContext = getCurrentAppContext();
if (appContext != nullptr && appContext->getManifest().appId == manifest.appId) {
return std::static_pointer_cast<I2cScannerApp>(appContext->getApp());
} else {
return nullptr;
}
}
#define PREFERENCES_BUS_INDEX_KEY "bus"
void I2cScannerApp::setLastBusIndex(int32_t index) {
auto prefs = Preferences("i2c_scanner");
prefs.putInt32(PREFERENCES_BUS_INDEX_KEY, index);
bool getPreferencesPath(std::string& outPath) {
char root[128];
if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) {
return false;
}
outPath = std::string(root) + "/i2c_scanner.properties";
return true;
}
int32_t I2cScannerApp::getLastBusIndex() {
auto prefs = Preferences("i2c_scanner");
void setLastBusIndex(int32_t index) {
std::string path;
if (!getPreferencesPath(path)) {
return;
}
Preferences* prefs = preferences_open(path.c_str());
if (prefs == nullptr) {
return;
}
preferences_put_int32(prefs, PREFERENCES_BUS_INDEX_KEY, index);
preferences_close(prefs);
}
int32_t getLastBusIndex() {
std::string path;
if (!getPreferencesPath(path)) {
return 0;
}
Preferences* prefs = preferences_open(path.c_str());
if (prefs == nullptr) {
return 0;
}
int32_t index = 0;
prefs.optInt32(PREFERENCES_BUS_INDEX_KEY, index);
preferences_opt_int32(prefs, PREFERENCES_BUS_INDEX_KEY, &index);
preferences_close(prefs);
return index;
}
// region Lifecycle
bool getPort(Context* ctx, struct Device** outPort) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
*outPort = ctx->portDevice;
ctx->mutex.unlock();
return true;
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "getPort");
return false;
}
}
bool addAddressToList(Context* ctx, uint8_t address) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
ctx->scannedAddresses.push_back(address);
ctx->mutex.unlock();
return true;
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "addAddressToList");
return false;
}
}
bool shouldStopScanTimer(Context* ctx) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
bool is_scanning = ctx->scanState == ScanStateScanning;
ctx->mutex.unlock();
return !is_scanning;
} else {
return true;
}
}
void updateViews(Context* ctx) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
if (ctx->scanState == ScanStateScanning) {
lv_label_set_text(ctx->scanButtonLabelWidget, STOP_SCAN_TEXT);
lv_obj_remove_flag(ctx->portDropdownWidget, LV_OBJ_FLAG_CLICKABLE);
} else {
lv_label_set_text(ctx->scanButtonLabelWidget, START_SCAN_TEXT);
lv_obj_add_flag(ctx->portDropdownWidget, LV_OBJ_FLAG_CLICKABLE);
}
lv_obj_clean(ctx->scanListWidget);
if (ctx->scanState == ScanStateStopped) {
lv_obj_remove_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN);
if (!ctx->scannedAddresses.empty()) {
for (auto address: ctx->scannedAddresses) {
std::string address_text = getAddressText(address);
lv_list_add_text(ctx->scanListWidget, address_text.c_str());
}
} else {
lv_list_add_text(ctx->scanListWidget, "No devices found");
}
} else {
lv_obj_add_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN);
}
ctx->mutex.unlock();
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "updateViews");
}
}
void updateViewsSafely(Context* ctx) {
lvgl_lock();
updateViews(ctx);
lvgl_unlock();
}
void onScanTimerFinished(Context* ctx) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
if (ctx->scanState == ScanStateScanning) {
ctx->scanState = ScanStateStopped;
}
ctx->mutex.unlock();
updateViewsSafely(ctx);
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "onScanTimerFinished");
}
}
void onScanTimer(Context* ctx) {
LOG_I(TAG, "Scan thread started");
Device* safe_port;
if (!getPort(ctx, &safe_port)) {
LOG_E(TAG, "Failed to get I2C port");
onScanTimerFinished(ctx);
return;
}
if (!device_is_ready(safe_port)) {
LOG_E(TAG, "I2C port not started");
onScanTimerFinished(ctx);
return;
}
for (uint8_t address = 1; address < 128; ++address) {
if (i2c_controller_has_device_at_address(safe_port, address, 10 / portTICK_PERIOD_MS) == ERROR_NONE) {
LOG_I(TAG, "Found device at address 0x%02X", address);
if (!shouldStopScanTimer(ctx)) {
addAddressToList(ctx, address);
} else {
break;
}
}
if (shouldStopScanTimer(ctx)) {
break;
}
}
LOG_I(TAG, "Scan thread finalizing");
onScanTimerFinished(ctx);
LOG_I(TAG, "Scan timer done");
}
bool hasScanThread(Context* ctx) {
bool has_thread;
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
has_thread = ctx->scanTimer != nullptr;
ctx->mutex.unlock();
return has_thread;
} else {
// Unsafe way
LOG_W(TAG, "Mutex acquisition timeout (%s)", "hasScanTimer");
return ctx->scanTimer != nullptr;
}
}
void stopScanning(Context* ctx) {
if (ctx->mutex.lock(250 / portTICK_PERIOD_MS)) {
assert(ctx->scanTimer != nullptr);
ctx->scanState = ScanStateStopped;
ctx->mutex.unlock();
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
}
}
void startScanning(Context* ctx) {
if (hasScanThread(ctx)) {
stopScanning(ctx);
}
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
ctx->scannedAddresses.clear();
lv_obj_add_flag(ctx->scanListWidget, LV_OBJ_FLAG_HIDDEN);
lv_obj_clean(ctx->scanListWidget);
ctx->scanState = ScanStateScanning;
ctx->scanTimer = std::make_unique<Timer>(Timer::Type::Once, 10, [ctx]{
onScanTimer(ctx);
});
ctx->scanTimer->start();
ctx->mutex.unlock();
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "startScanning");
}
}
void selectBus(Context* ctx, int32_t selected) {
struct Device* found_device;
if (!getActivePortAtIndex(selected, &found_device)) {
return;
}
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
ctx->scannedAddresses.clear();
ctx->portDevice = found_device;
ctx->scanState = ScanStateInitial;
ctx->mutex.unlock();
}
LOG_I(TAG, "Selected %d", (int)selected);
setLastBusIndex(selected);
startScanning(ctx);
updateViews(ctx);
}
// region Callbacks
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onSelectBus(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t selected = lv_dropdown_get_selected(dropdown);
selectBus(ctx, selected);
}
void onPressScan(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
if (ctx->scanState == ScanStateScanning) {
stopScanning(ctx);
} else {
startScanning(ctx);
}
updateViews(ctx);
}
// endregion Callbacks
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
void I2cScannerApp::onShow(AppContext& app, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
auto* toolbar = lvgl_toolbar_create(parent, "I2C Scanner");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
@@ -115,286 +332,103 @@ void I2cScannerApp::onShow(AppContext& app, lv_obj_t* parent) {
auto* scan_button = lv_button_create(wrapper);
lv_obj_set_width(scan_button, LV_PCT(48));
lv_obj_align(scan_button, LV_ALIGN_TOP_LEFT, 0, 1); // Shift 1 pixel to align with selection box
lv_obj_add_event_cb(scan_button, onPressScanCallback, LV_EVENT_SHORT_CLICKED, this);
lv_obj_add_event_cb(scan_button, onPressScan, LV_EVENT_SHORT_CLICKED, ctx);
auto* scan_button_label = lv_label_create(scan_button);
lv_obj_align(scan_button_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(scan_button_label, START_SCAN_TEXT);
scanButtonLabelWidget = scan_button_label;
ctx->scanButtonLabelWidget = scan_button_label;
auto* port_dropdown = lv_dropdown_create(wrapper);
std::string dropdown_items = getPortNamesForDropdown();
lv_dropdown_set_options(port_dropdown, dropdown_items.c_str());
lv_obj_set_width(port_dropdown, LV_PCT(48));
lv_obj_align(port_dropdown, LV_ALIGN_TOP_RIGHT, 0, 0);
lv_obj_add_event_cb(port_dropdown, onSelectBusCallback, LV_EVENT_VALUE_CHANGED, this);
lv_obj_add_event_cb(port_dropdown, onSelectBus, LV_EVENT_VALUE_CHANGED, ctx);
auto selected_bus = getLastBusIndex();
lv_dropdown_set_selected(port_dropdown, selected_bus);
portDropdownWidget = port_dropdown;
ctx->portDropdownWidget = port_dropdown;
auto* scan_list = lv_list_create(main_wrapper);
lv_obj_set_style_margin_top(scan_list, 8, 0);
lv_obj_set_width(scan_list, LV_PCT(100));
lv_obj_set_height(scan_list, LV_SIZE_CONTENT);
lv_obj_add_flag(scan_list, LV_OBJ_FLAG_HIDDEN);
scanListWidget = scan_list;
ctx->scanListWidget = scan_list;
struct Device* dummy;
if (getActivePortAtIndex(selected_bus, &dummy)) {
selectBus(selected_bus);
selectBus(ctx, selected_bus);
} else if (getActivePortAtIndex(0, &dummy)) {
lv_dropdown_set_selected(port_dropdown, 0);
selectBus(0);
selectBus(ctx, 0);
}
}
void I2cScannerApp::onHide(AppContext& app) {
// Mirrors the old model's onHide(): stop any in-flight scan before this app's task exits
// (APP_EVENT_CLOSE).
void stopScanningIfRunning(Context* ctx) {
bool isRunning = false;
if (mutex.lock(250 / portTICK_PERIOD_MS)) {
auto* timer = scanTimer.get();
if (ctx->mutex.lock(250 / portTICK_PERIOD_MS)) {
auto* timer = ctx->scanTimer.get();
if (timer != nullptr) {
isRunning = timer->isRunning();
}
mutex.unlock();
ctx->mutex.unlock();
} else {
return;
}
if (isRunning) {
stopScanning();
stopScanning(ctx);
}
}
// endregion Lifecycle
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx;
ctx.appInstanceId = appInstanceId;
// region Callbacks
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
void I2cScannerApp::onSelectBusCallback(lv_event_t* event) {
auto* app = (I2cScannerApp*)lv_event_get_user_data(event);
if (app != nullptr) {
app->onSelectBus(event);
}
}
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
void I2cScannerApp::onPressScanCallback(lv_event_t* event) {
auto* app = (I2cScannerApp*)lv_event_get_user_data(event);
if (app != nullptr) {
app->onPressScan(event);
}
}
// endregion Callbacks
bool I2cScannerApp::getPort(struct Device** outPort) {
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
*outPort = this->portDevice;
mutex.unlock();
return true;
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "getPort");
return false;
}
}
bool I2cScannerApp::addAddressToList(uint8_t address) {
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
scannedAddresses.push_back(address);
mutex.unlock();
return true;
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "addAddressToList");
return false;
}
}
bool I2cScannerApp::shouldStopScanTimer() {
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
bool is_scanning = scanState == ScanStateScanning;
mutex.unlock();
return !is_scanning;
} else {
return true;
}
}
void I2cScannerApp::onScanTimer() {
LOG_I(TAG, "Scan thread started");
Device* safe_port;
if (!getPort(&safe_port)) {
LOG_E(TAG, "Failed to get I2C port");
onScanTimerFinished();
return;
}
if (!device_is_ready(safe_port)) {
LOG_E(TAG, "I2C port not started");
onScanTimerFinished();
return;
}
for (uint8_t address = 1; address < 128; ++address) {
if (i2c_controller_has_device_at_address(safe_port, address, 10 / portTICK_PERIOD_MS) == ERROR_NONE) {
LOG_I(TAG, "Found device at address 0x%02X", address);
if (!shouldStopScanTimer()) {
addAddressToList(address);
} else {
break;
}
}
if (shouldStopScanTimer()) {
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
}
LOG_I(TAG, "Scan thread finalizing");
onScanTimerFinished();
LOG_I(TAG, "Scan timer done");
}
bool I2cScannerApp::hasScanThread() {
bool has_thread;
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
has_thread = scanTimer != nullptr;
mutex.unlock();
return has_thread;
} else {
// Unsafe way
LOG_W(TAG, "Mutex acquisition timeout (%s)", "hasScanTimer");
return scanTimer != nullptr;
}
}
void I2cScannerApp::startScanning() {
if (hasScanThread()) {
stopScanning();
}
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
scannedAddresses.clear();
lv_obj_add_flag(scanListWidget, LV_OBJ_FLAG_HIDDEN);
lv_obj_clean(scanListWidget);
scanState = ScanStateScanning;
scanTimer = std::make_unique<Timer>(Timer::Type::Once, 10, [this]{
onScanTimer();
});
scanTimer->start();
mutex.unlock();
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "startScanning");
}
}
void I2cScannerApp::stopScanning() {
if (mutex.lock(250 / portTICK_PERIOD_MS)) {
assert(scanTimer != nullptr);
scanState = ScanStateStopped;
mutex.unlock();
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
}
}
void I2cScannerApp::onSelectBus(lv_event_t* event) {
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t selected = lv_dropdown_get_selected(dropdown);
selectBus(selected);
}
void I2cScannerApp::selectBus(int32_t selected) {
struct Device* found_device;
if (!getActivePortAtIndex(selected, &found_device)) {
return;
}
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
scannedAddresses.clear();
portDevice = found_device;
scanState = ScanStateInitial;
mutex.unlock();
}
LOG_I(TAG, "Selected %d", (int)selected);
setLastBusIndex(selected);
startScanning();
updateViews();
}
void I2cScannerApp::onPressScan(lv_event_t* event) {
if (scanState == ScanStateScanning) {
stopScanning();
} else {
startScanning();
}
updateViews();
}
void I2cScannerApp::updateViews() {
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
if (scanState == ScanStateScanning) {
lv_label_set_text(scanButtonLabelWidget, STOP_SCAN_TEXT);
lv_obj_remove_flag(portDropdownWidget, LV_OBJ_FLAG_CLICKABLE);
} else {
lv_label_set_text(scanButtonLabelWidget, START_SCAN_TEXT);
lv_obj_add_flag(portDropdownWidget, LV_OBJ_FLAG_CLICKABLE);
switch (event.type) {
case APP_EVENT_CLOSE:
stopScanningIfRunning(&ctx);
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
lv_obj_clean(scanListWidget);
if (scanState == ScanStateStopped) {
lv_obj_remove_flag(scanListWidget, LV_OBJ_FLAG_HIDDEN);
if (!scannedAddresses.empty()) {
for (auto address: scannedAddresses) {
std::string address_text = getAddressText(address);
lv_list_add_text(scanListWidget, address_text.c_str());
}
} else {
lv_list_add_text(scanListWidget, "No devices found");
}
} else {
lv_obj_add_flag(scanListWidget, LV_OBJ_FLAG_HIDDEN);
}
mutex.unlock();
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "updateViews");
}
}
void I2cScannerApp::updateViewsSafely() {
lvgl_lock();
updateViews();
lvgl_unlock();
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
void I2cScannerApp::onScanTimerFinished() {
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
if (scanState == ScanStateScanning) {
scanState = ScanStateStopped;
}
mutex.unlock();
updateViewsSafely();
} else {
LOG_W(TAG, "Mutex acquisition timeout (%s)", "onScanTimerFinished");
}
}
extern const AppManifest manifest = {
.appId = "I2cScanner",
.appName = "I2C Scanner",
.appIcon = LVGL_ICON_SHARED_SEARCH,
.appCategory = Category::System,
.createApp = create<I2cScannerApp>
};
LaunchId start() {
return app::start(manifest.appId);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "I2cScanner",
.name = "I2C Scanner",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
uint32_t start() {
uint32_t instanceId = 0;
app_manager_start(manifest.id, &instanceId);
return instanceId;
}
} // namespace
+111 -51
View File
@@ -1,76 +1,136 @@
#include <Tactility/lvgl/Lvgl.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/StringUtils.h>
#include <tactility/check.h>
#include <tactility/log.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/widgets/toolbar.h>
#include <lvgl.h>
#include <string>
namespace tt::app::imageviewer {
extern const AppManifest manifest;
extern const ::AppManifest manifest;
constexpr auto* TAG = "ImageViewer";
constexpr auto* IMAGE_VIEWER_FILE_ARGUMENT = "file";
class ImageViewerApp final : public App {
namespace {
void onShow(AppContext& app, lv_obj_t* parent) override {
auto wrapper = lv_obj_create(parent);
lv_obj_set_size(wrapper, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_border_width(wrapper, 0, 0);
lv_obj_set_style_pad_all(wrapper, 0, 0);
lv_obj_set_style_pad_gap(wrapper, 0, 0);
struct Context {
uint32_t appInstanceId;
std::string filePath;
};
auto toolbar = lvgl::toolbar_create(wrapper, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
auto* image_wrapper = lv_obj_create(wrapper);
lv_obj_align_to(image_wrapper, toolbar, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 0);
lv_obj_set_width(image_wrapper, LV_PCT(100));
auto parent_height = lv_obj_get_height(wrapper);
auto toolbar_height = lv_obj_get_height(toolbar);
lv_obj_set_height(image_wrapper, parent_height - toolbar_height);
lv_obj_set_flex_flow(image_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(image_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(image_wrapper, 0, 0);
lv_obj_set_style_pad_gap(image_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(image_wrapper);
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
auto* image = lv_image_create(image_wrapper);
lv_obj_align(image, LV_ALIGN_CENTER, 0, 0);
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
auto* file_label = lv_label_create(wrapper);
lv_obj_align_to(file_label, wrapper, LV_ALIGN_BOTTOM_LEFT, 0, 0);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_size(wrapper, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_border_width(wrapper, 0, 0);
lv_obj_set_style_pad_all(wrapper, 0, 0);
lv_obj_set_style_pad_gap(wrapper, 0, 0);
std::shared_ptr<const Bundle> bundle = app.getParameters();
check(bundle != nullptr, "Parameters not set");
std::string file_argument;
if (bundle->optString(IMAGE_VIEWER_FILE_ARGUMENT, file_argument)) {
std::string prefixed_path = lvgl::PATH_PREFIX + file_argument;
LOG_I(TAG, "Opening %s", prefixed_path.c_str());
lv_img_set_src(image, prefixed_path.c_str());
auto path = string::getLastPathSegment(file_argument);
lv_label_set_text(file_label, path.c_str());
} else {
lv_label_set_text(file_label, "File not found");
auto* toolbar = lvgl_toolbar_create(wrapper, "Image Viewer");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
auto* image_wrapper = lv_obj_create(wrapper);
lv_obj_align_to(image_wrapper, toolbar, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 0);
lv_obj_set_width(image_wrapper, LV_PCT(100));
auto parent_height = lv_obj_get_height(wrapper);
auto toolbar_height = lv_obj_get_height(toolbar);
lv_obj_set_height(image_wrapper, parent_height - toolbar_height);
lv_obj_set_flex_flow(image_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(image_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_all(image_wrapper, 0, 0);
lv_obj_set_style_pad_gap(image_wrapper, 0, 0);
lvgl::obj_set_style_bg_invisible(image_wrapper);
auto* image = lv_image_create(image_wrapper);
lv_obj_align(image, LV_ALIGN_CENTER, 0, 0);
auto* file_label = lv_label_create(wrapper);
lv_obj_align_to(file_label, wrapper, LV_ALIGN_BOTTOM_LEFT, 0, 0);
if (!ctx->filePath.empty()) {
std::string prefixed_path = lvgl::PATH_PREFIX + ctx->filePath;
LOG_I(TAG, "Opening %s", prefixed_path.c_str());
lv_img_set_src(image, prefixed_path.c_str());
auto path = string::getLastPathSegment(ctx->filePath);
lv_label_set_text(file_label, path.c_str());
} else {
lv_label_set_text(file_label, "File not found");
}
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
check(argc > 0, "Parameters not set");
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.filePath = argv[0];
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
};
extern const AppManifest manifest = {
.appId = "ImageViewer",
.appName = "Image Viewer",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<ImageViewerApp>
};
window_manager_remove(window);
app_event_unsubscribe(&sub);
LaunchId start(const std::string& file) {
auto parameters = std::make_shared<Bundle>();
parameters->putString(IMAGE_VIEWER_FILE_ARGUMENT, file);
return app::start(manifest.appId, parameters);
return 0;
}
} // namespace
void start(const std::string& file) {
const char* argv[] = { file.c_str() };
uint32_t instanceId = 0;
app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId);
}
extern const ::AppManifest manifest = {
.id = "ImageViewer",
.name = "Image Viewer",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
+124 -94
View File
@@ -1,128 +1,158 @@
#include <Tactility/app/inputdialog/InputDialog.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/widgets/toolbar.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/TactilityCore.h>
#include <tactility/log.h>
#include <lvgl.h>
namespace tt::app::inputdialog {
constexpr auto* PARAMETER_BUNDLE_KEY_TITLE = "title";
constexpr auto* PARAMETER_BUNDLE_KEY_MESSAGE = "message";
constexpr auto* PARAMETER_BUNDLE_KEY_PREFILLED = "prefilled";
constexpr auto* RESULT_BUNDLE_KEY_RESULT = "result";
constexpr auto* DEFAULT_TITLE = "Input";
constexpr auto* TAG = "InputDialog";
extern const AppManifest manifest;
class InputDialogApp;
extern const ::AppManifest manifest;
LaunchId start(const std::string& title, const std::string& message, const std::string& prefilled) {
auto bundle = std::make_shared<Bundle>();
bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title);
bundle->putString(PARAMETER_BUNDLE_KEY_MESSAGE, message);
bundle->putString(PARAMETER_BUNDLE_KEY_PREFILLED, prefilled);
return app::start(manifest.appId, bundle);
namespace {
struct Context {
uint32_t appInstanceId;
// Set once in appMain() from its own argc/argv parameters, read by createWidgets() - see
// AlertDialog.cpp's Context::argc/argv for why this is safe without a lock.
int argc = 0;
char** argv = nullptr;
// The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this
// is a plain (non-atomic) field safely shared between the LVGL thread (writer, before
// emitting APP_EVENT_CLOSE) and this dialog's own thread (reader, after waking from it).
int32_t result = 1; // Cancelled - safety-net default if closed without pressing a button
};
struct ButtonContext {
Context* ctx;
/** Non-null for OK (read at press time), NULL for Cancel. */
lv_obj_t* textarea;
};
// The last text entered via OK. Static rather than per-instance: simple, and in practice only
// one InputDialog is ever open at a time. Written on the LVGL thread (onButtonPressed(), before
// emitting APP_EVENT_CLOSE); read by the parent via getLastText() after receiving that event -
// safe without a lock for the same reason Context::result is (see AlertDialog.cpp).
std::string lastText;
void onButtonDeleted(lv_event_t* e) {
delete static_cast<ButtonContext*>(lv_event_get_user_data(e));
}
std::string getResult(const Bundle& bundle) {
std::string result;
bundle.optString(RESULT_BUNDLE_KEY_RESULT, result);
return result;
}
static std::string getTitleParameter(const std::shared_ptr<const Bundle>& bundle) {
std::string result;
if (bundle->optString(PARAMETER_BUNDLE_KEY_TITLE, result)) {
return result;
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");
lastText = lv_textarea_get_text(btnCtx->textarea);
btnCtx->ctx->result = 0;
} else {
return DEFAULT_TITLE;
LOG_I(TAG, "Cancel pressed");
btnCtx->ctx->result = 1;
}
// Async, non-blocking - see AlertDialog.cpp's onButtonPressed() for why this must not
// call app_manager_stop() directly (would deadlock against the LVGL lock).
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(btnCtx->ctx->appInstanceId, &event);
}
class InputDialogApp final : public App {
void createButton(Context* ctx, lv_obj_t* parent, const std::string& text, lv_obj_t* textarea) {
lv_obj_t* button = lv_button_create(parent);
lv_obj_t* button_label = lv_label_create(button);
lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(button_label, text.c_str());
auto* btnCtx = new ButtonContext { ctx, textarea };
lv_obj_add_event_cb(button, onButtonPressed, LV_EVENT_SHORT_CLICKED, btnCtx);
lv_obj_add_event_cb(button, onButtonDeleted, LV_EVENT_DELETE, btnCtx);
}
static void createButton(lv_obj_t* parent, const std::string& text, void* callbackContext) {
lv_obj_t* button = lv_button_create(parent);
lv_obj_t* button_label = lv_label_create(button);
lv_obj_align(button_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(button_label, text.c_str());
lv_obj_add_event_cb(button, onButtonClickedCallback, LV_EVENT_SHORT_CLICKED, callbackContext);
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
// argv layout: [0]=title, [1]=message, [2]=prefilled.
char** argv = ctx->argv;
auto* toolbar = lvgl_toolbar_create(parent, argv[0]);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
auto* message_label = lv_label_create(parent);
lv_obj_align(message_label, LV_ALIGN_CENTER, 0, -20);
lv_obj_set_width(message_label, LV_PCT(80));
lv_label_set_text(message_label, argv[1]);
lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP);
auto* textarea = lv_textarea_create(parent);
lv_obj_align_to(textarea, message_label, LV_ALIGN_OUT_BOTTOM_MID, 0, 4);
lv_textarea_set_one_line(textarea, true);
if (argv[2][0] != '\0') {
lv_textarea_set_text(textarea, argv[2]);
}
static void onButtonClickedCallback(lv_event_t* e) {
auto app = std::static_pointer_cast<InputDialogApp>(getCurrentApp());
assert(app != nullptr);
app->onButtonClicked(e);
}
auto* button_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(button_wrapper, 0, 0);
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(button_wrapper, 0, 0);
lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4);
void onButtonClicked(lv_event_t* e) {
auto user_data = lv_event_get_user_data(e);
int index = (user_data != 0) ? 0 : 1;
LOG_I(TAG, "Selected item at index %d", index);
if (index == 0) {
auto bundle = std::make_unique<Bundle>();
const char* text = lv_textarea_get_text((lv_obj_t*)user_data);
bundle->putString(RESULT_BUNDLE_KEY_RESULT, text);
setResult(Result::Ok, std::move(bundle));
} else {
setResult(Result::Cancelled);
createButton(ctx, button_wrapper, "OK", textarea);
createButton(ctx, button_wrapper, "Cancel", nullptr);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx { appInstanceId };
ctx.argc = argc;
ctx.argv = argv;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
while (true) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
if (event.type == APP_EVENT_CLOSE) {
app_manager_finish(appInstanceId); // no-op: modal children never supersede anything
break;
}
stop(manifest.appId);
}
public:
window_manager_remove(window);
app_event_unsubscribe(&sub);
void onShow(AppContext& app, lv_obj_t* parent) override {
auto parameters = app.getParameters();
check(parameters != nullptr, "Parameters missing");
return ctx.result;
}
std::string title = getTitleParameter(app.getParameters());
auto* toolbar = lvgl_toolbar_create(parent, title.c_str());
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
} // namespace
auto* message_label = lv_label_create(parent);
lv_obj_align(message_label, LV_ALIGN_CENTER, 0, -20);
lv_obj_set_width(message_label, LV_PCT(80));
uint32_t start(uint32_t callerAppInstanceId, const std::string& title, const std::string& message, const std::string& prefilled) {
const char* argv[] = { title.c_str(), message.c_str(), prefilled.c_str() };
uint32_t instanceId = 0;
app_manager_start_for_result(manifest.id, callerAppInstanceId, 3, argv, &instanceId);
return instanceId;
}
std::string message;
if (parameters->optString(PARAMETER_BUNDLE_KEY_MESSAGE, message)) {
lv_label_set_text(message_label, message.c_str());
lv_label_set_long_mode(message_label, LV_LABEL_LONG_WRAP);
}
std::string getLastText() {
return lastText;
}
auto* textarea = lv_textarea_create(parent);
lv_obj_align_to(textarea, message_label, LV_ALIGN_OUT_BOTTOM_MID, 0, 4);
lv_textarea_set_one_line(textarea, true);
std::string prefilled;
if (parameters->optString(PARAMETER_BUNDLE_KEY_PREFILLED, prefilled)) {
lv_textarea_set_text(textarea, prefilled.c_str());
}
auto* button_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_size(button_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(button_wrapper, 0, 0);
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_border_width(button_wrapper, 0, 0);
lv_obj_align(button_wrapper, LV_ALIGN_BOTTOM_MID, 0, -4);
createButton(button_wrapper, "OK", textarea);
createButton(button_wrapper, "Cancel", nullptr);
}
};
extern const AppManifest manifest = {
.appId = "InputDialog",
.appName = "Input Dialog",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<InputDialogApp>
extern const ::AppManifest manifest = {
.id = "InputDialog",
.name = "Input Dialog",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
}
@@ -1,4 +1,3 @@
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h>
#include <tactility/device.h>
#include <tactility/drivers/backlight.h>
@@ -10,10 +9,16 @@
#ifdef ESP_PLATFORM
#include <Tactility/service/displayidle/DisplayIdleService.h>
#endif
#include <Tactility/app/App.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/settings/DisplaySettings.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/widgets/toolbar.h>
#include <lvgl.h>
#ifdef ESP_PLATFORM
@@ -22,9 +27,23 @@
namespace tt::app::kerneldisplay {
extern const ::AppManifest manifest;
constexpr auto* TAG = "KernelDisplay";
static Device* getBacklightDevice() {
namespace {
struct Context {
uint32_t appInstanceId;
settings::display::DisplaySettings displaySettings;
bool displaySettingsUpdated = false;
lv_obj_t* timeoutSwitch = nullptr;
lv_obj_t* timeoutDropdown = nullptr;
lv_obj_t* screensaverDropdown = nullptr;
};
Device* getBacklightDevice() {
Device* display;
check(device_get_first_by_type(&DISPLAY_TYPE, &display) == ERROR_NONE);
// Boards not yet migrated to the kernel display driver register a placeholder device (so the
@@ -39,253 +58,292 @@ static Device* getBacklightDevice() {
return backlight;
}
class KernelDisplayApp final : public App {
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
settings::display::DisplaySettings displaySettings;
bool displaySettingsUpdated = false;
lv_obj_t* timeoutSwitch = nullptr;
lv_obj_t* timeoutDropdown = nullptr;
lv_obj_t* screensaverDropdown = nullptr;
void onBacklightSliderEvent(lv_event_t* event) {
auto* slider = static_cast<lv_obj_t*>(lv_event_get_target(event));
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* backlight = getBacklightDevice();
assert(backlight != nullptr);
static void onBacklightSliderEvent(lv_event_t* event) {
auto* slider = static_cast<lv_obj_t*>(lv_event_get_target(event));
auto* app = static_cast<KernelDisplayApp*>(lv_event_get_user_data(event));
auto* backlight = getBacklightDevice();
assert(backlight != nullptr);
int32_t slider_value = lv_slider_get_value(slider);
ctx->displaySettings.backlightDuty = static_cast<uint8_t>(slider_value);
ctx->displaySettingsUpdated = true;
backlight_set_brightness(backlight, ctx->displaySettings.backlightDuty);
}
int32_t slider_value = lv_slider_get_value(slider);
app->displaySettings.backlightDuty = static_cast<uint8_t>(slider_value);
app->displaySettingsUpdated = true;
backlight_set_brightness(backlight, app->displaySettings.backlightDuty);
void onOrientationSet(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t selected_index = lv_dropdown_get_selected(dropdown);
LOG_I(TAG, "Selected %u", (unsigned)selected_index);
auto selected_orientation = static_cast<settings::display::Orientation>(selected_index);
if (selected_orientation != ctx->displaySettings.orientation) {
ctx->displaySettings.orientation = selected_orientation;
ctx->displaySettingsUpdated = true;
lv_display_set_rotation(lv_display_get_default(), settings::display::toLvglDisplayRotation(selected_orientation));
}
}
static void onOrientationSet(lv_event_t* event) {
auto* app = static_cast<KernelDisplayApp*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t selected_index = lv_dropdown_get_selected(dropdown);
LOG_I(TAG, "Selected %u", (unsigned)selected_index);
auto selected_orientation = static_cast<settings::display::Orientation>(selected_index);
if (selected_orientation != app->displaySettings.orientation) {
app->displaySettings.orientation = selected_orientation;
app->displaySettingsUpdated = true;
lv_display_set_rotation(lv_display_get_default(), settings::display::toLvglDisplayRotation(selected_orientation));
}
}
static void onTimeoutSwitch(lv_event_t* event) {
auto* app = static_cast<KernelDisplayApp*>(lv_event_get_user_data(event));
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
app->displaySettings.backlightTimeoutEnabled = enabled;
app->displaySettingsUpdated = true;
if (app->timeoutDropdown) {
if (enabled) {
lv_obj_clear_state(app->timeoutDropdown, LV_STATE_DISABLED);
if (app->screensaverDropdown) {
lv_obj_clear_state(app->screensaverDropdown, LV_STATE_DISABLED);
}
} else {
lv_obj_add_state(app->timeoutDropdown, LV_STATE_DISABLED);
if (app->screensaverDropdown) {
lv_obj_add_state(app->screensaverDropdown, LV_STATE_DISABLED);
}
void onTimeoutSwitch(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* sw = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool enabled = lv_obj_has_state(sw, LV_STATE_CHECKED);
ctx->displaySettings.backlightTimeoutEnabled = enabled;
ctx->displaySettingsUpdated = true;
if (ctx->timeoutDropdown) {
if (enabled) {
lv_obj_clear_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
if (ctx->screensaverDropdown) {
lv_obj_clear_state(ctx->screensaverDropdown, LV_STATE_DISABLED);
}
} else {
lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
if (ctx->screensaverDropdown) {
lv_obj_add_state(ctx->screensaverDropdown, LV_STATE_DISABLED);
}
}
}
}
static void onTimeoutChanged(lv_event_t* event) {
auto* app = static_cast<KernelDisplayApp*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t idx = lv_dropdown_get_selected(dropdown);
// Map dropdown index to ms: 0=15s,1=30s,2=1m,3=2m,4=5m,5=Never
static const uint32_t values_ms[] = {15000, 30000, 60000, 120000, 300000, 0};
if (idx < (sizeof(values_ms)/sizeof(values_ms[0]))) {
app->displaySettings.backlightTimeoutMs = values_ms[idx];
app->displaySettingsUpdated = true;
void onTimeoutChanged(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t idx = lv_dropdown_get_selected(dropdown);
// Map dropdown index to ms: 0=15s,1=30s,2=1m,3=2m,4=5m,5=Never
static const uint32_t values_ms[] = {15000, 30000, 60000, 120000, 300000, 0};
if (idx < (sizeof(values_ms)/sizeof(values_ms[0]))) {
ctx->displaySettings.backlightTimeoutMs = values_ms[idx];
ctx->displaySettingsUpdated = true;
}
}
void onScreensaverChanged(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t idx = lv_dropdown_get_selected(dropdown);
// Validate index bounds before casting to enum
if (idx >= static_cast<uint32_t>(settings::display::ScreensaverType::Count)) {
return;
}
auto selected_type = static_cast<settings::display::ScreensaverType>(idx);
if (selected_type != ctx->displaySettings.screensaverType) {
ctx->displaySettings.screensaverType = selected_type;
ctx->displaySettingsUpdated = true;
}
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->displaySettings = settings::display::loadOrGetDefault();
auto ui_density = lvgl_get_ui_density();
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
auto* backlight = getBacklightDevice();
auto* toolbar = lvgl_toolbar_create(parent, "Display");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// Backlight slider
// Note: no gamma slider here - unlike HalDisplayApp (app/display/Display.cpp), the kernel
// DisplayApi has no gamma curve control yet.
if (backlight != nullptr) {
bool is_on_off_brightness = backlight_get_min_brightness(backlight) == 0 && backlight_get_max_brightness(backlight) == 1;
if (!is_on_off_brightness) {
auto* brightness_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(brightness_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(brightness_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(brightness_wrapper, 0, LV_STATE_DEFAULT);
if (ui_density != LVGL_UI_DENSITY_COMPACT) {
lv_obj_set_style_pad_ver(brightness_wrapper, 4, LV_STATE_DEFAULT);
}
auto* brightness_label = lv_label_create(brightness_wrapper);
lv_label_set_text(brightness_label, "Brightness");
lv_obj_align(brightness_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* brightness_slider = lv_slider_create(brightness_wrapper);
lv_obj_set_width(brightness_slider, LV_PCT(50));
lv_obj_align(brightness_slider, LV_ALIGN_RIGHT_MID, 0, 0);
lv_slider_set_range(brightness_slider, backlight_get_min_brightness(backlight), backlight_get_max_brightness(backlight));
lv_obj_add_event_cb(brightness_slider, onBacklightSliderEvent, LV_EVENT_VALUE_CHANGED, ctx);
lv_slider_set_value(brightness_slider, ctx->displaySettings.backlightDuty, LV_ANIM_OFF);
}
}
static void onScreensaverChanged(lv_event_t* event) {
auto* app = static_cast<KernelDisplayApp*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t idx = lv_dropdown_get_selected(dropdown);
// Validate index bounds before casting to enum
if (idx >= static_cast<uint32_t>(settings::display::ScreensaverType::Count)) {
return;
// Orientation
auto* orientation_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(orientation_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(orientation_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(orientation_wrapper, 0, LV_STATE_DEFAULT);
auto* orientation_label = lv_label_create(orientation_wrapper);
lv_label_set_text(orientation_label, "Orientation");
lv_obj_align(orientation_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* orientation_dropdown = lv_dropdown_create(orientation_wrapper);
// Note: order correlates with settings::display::Orientation item order
lv_dropdown_set_options(orientation_dropdown, "Landscape\nPortrait Right\nLandscape Flipped\nPortrait Left");
lv_obj_align(orientation_dropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(orientation_dropdown, onOrientationSet, LV_EVENT_VALUE_CHANGED, ctx);
// Set the dropdown to match current orientation enum
lv_dropdown_set_selected(orientation_dropdown, static_cast<uint16_t>(ctx->displaySettings.orientation));
// Screen timeout
// Note: DisplayIdleService doesn't act on these settings for kernel-driver displays yet
// (it only looks up the deprecated tt::hal::display::DisplayDevice), so these currently
// just get saved without taking effect. Kept for parity/forward-compatibility.
if (backlight != nullptr) {
auto* timeout_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(timeout_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timeout_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(timeout_wrapper, 0, LV_STATE_DEFAULT);
auto* timeout_label = lv_label_create(timeout_wrapper);
lv_label_set_text(timeout_label, "Auto screen off");
lv_obj_align(timeout_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->timeoutSwitch = lv_switch_create(timeout_wrapper);
if (ctx->displaySettings.backlightTimeoutEnabled) {
lv_obj_add_state(ctx->timeoutSwitch, LV_STATE_CHECKED);
}
auto selected_type = static_cast<settings::display::ScreensaverType>(idx);
if (selected_type != app->displaySettings.screensaverType) {
app->displaySettings.screensaverType = selected_type;
app->displaySettingsUpdated = true;
lv_obj_align(ctx->timeoutSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->timeoutSwitch, onTimeoutSwitch, LV_EVENT_VALUE_CHANGED, ctx);
auto* timeout_select_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
auto* timeout_value_label = lv_label_create(timeout_select_wrapper);
lv_label_set_text(timeout_value_label, "Timeout");
lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->timeoutDropdown = lv_dropdown_create(timeout_select_wrapper);
lv_dropdown_set_options(ctx->timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever");
lv_obj_align(ctx->timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, ctx);
// Initialize dropdown selection from settings
uint32_t ms = ctx->displaySettings.backlightTimeoutMs;
uint32_t idx = 2; // default 1 minute
if (ms == 15000) idx = 0;
else if (ms == 30000)
idx = 1;
else if (ms == 60000)
idx = 2;
else if (ms == 120000)
idx = 3;
else if (ms == 300000)
idx = 4;
else if (ms == 0)
idx = 5;
lv_dropdown_set_selected(ctx->timeoutDropdown, idx);
if (!ctx->displaySettings.backlightTimeoutEnabled) {
lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
}
// Screensaver type
auto* screensaver_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(screensaver_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(screensaver_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(screensaver_wrapper, 0, LV_STATE_DEFAULT);
auto* screensaver_label = lv_label_create(screensaver_wrapper);
lv_label_set_text(screensaver_label, "Screensaver");
lv_obj_align(screensaver_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->screensaverDropdown = lv_dropdown_create(screensaver_wrapper);
// Note: order correlates with settings::display::ScreensaverType enum order
lv_dropdown_set_options(ctx->screensaverDropdown, "None\nBouncing Balls\nMystify\nMatrix Rain\nStackChan");
lv_obj_align(ctx->screensaverDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->screensaverDropdown, onScreensaverChanged, LV_EVENT_VALUE_CHANGED, ctx);
lv_dropdown_set_selected(ctx->screensaverDropdown, static_cast<uint16_t>(ctx->displaySettings.screensaverType));
if (!ctx->displaySettings.backlightTimeoutEnabled) {
lv_obj_add_state(ctx->screensaverDropdown, LV_STATE_DISABLED);
}
}
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
displaySettings = settings::display::loadOrGetDefault();
auto ui_density = lvgl_get_ui_density();
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
auto* backlight = getBacklightDevice();
lvgl::toolbar_create(parent, app);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// Backlight slider
// Note: no gamma slider here - unlike HalDisplayApp (app/display/Display.cpp), the kernel
// DisplayApi has no gamma curve control yet.
if (backlight != nullptr) {
bool is_on_off_brightness = backlight_get_min_brightness(backlight) == 0 && backlight_get_max_brightness(backlight) == 1;
if (!is_on_off_brightness) {
auto* brightness_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(brightness_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(brightness_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(brightness_wrapper, 0, LV_STATE_DEFAULT);
if (ui_density != LVGL_UI_DENSITY_COMPACT) {
lv_obj_set_style_pad_ver(brightness_wrapper, 4, LV_STATE_DEFAULT);
}
auto* brightness_label = lv_label_create(brightness_wrapper);
lv_label_set_text(brightness_label, "Brightness");
lv_obj_align(brightness_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* brightness_slider = lv_slider_create(brightness_wrapper);
lv_obj_set_width(brightness_slider, LV_PCT(50));
lv_obj_align(brightness_slider, LV_ALIGN_RIGHT_MID, 0, 0);
lv_slider_set_range(brightness_slider, backlight_get_min_brightness(backlight), backlight_get_max_brightness(backlight));
lv_obj_add_event_cb(brightness_slider, onBacklightSliderEvent, LV_EVENT_VALUE_CHANGED, this);
lv_slider_set_value(brightness_slider, displaySettings.backlightDuty, LV_ANIM_OFF);
}
}
// Orientation
auto* orientation_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(orientation_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(orientation_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(orientation_wrapper, 0, LV_STATE_DEFAULT);
auto* orientation_label = lv_label_create(orientation_wrapper);
lv_label_set_text(orientation_label, "Orientation");
lv_obj_align(orientation_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* orientation_dropdown = lv_dropdown_create(orientation_wrapper);
// Note: order correlates with settings::display::Orientation item order
lv_dropdown_set_options(orientation_dropdown, "Landscape\nPortrait Right\nLandscape Flipped\nPortrait Left");
lv_obj_align(orientation_dropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(orientation_dropdown, onOrientationSet, LV_EVENT_VALUE_CHANGED, this);
// Set the dropdown to match current orientation enum
lv_dropdown_set_selected(orientation_dropdown, static_cast<uint16_t>(displaySettings.orientation));
// Screen timeout
// Note: DisplayIdleService doesn't act on these settings for kernel-driver displays yet
// (it only looks up the deprecated tt::hal::display::DisplayDevice), so these currently
// just get saved without taking effect. Kept for parity/forward-compatibility.
if (backlight != nullptr) {
auto* timeout_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(timeout_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timeout_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(timeout_wrapper, 0, LV_STATE_DEFAULT);
auto* timeout_label = lv_label_create(timeout_wrapper);
lv_label_set_text(timeout_label, "Auto screen off");
lv_obj_align(timeout_label, LV_ALIGN_LEFT_MID, 0, 0);
timeoutSwitch = lv_switch_create(timeout_wrapper);
if (displaySettings.backlightTimeoutEnabled) {
lv_obj_add_state(timeoutSwitch, LV_STATE_CHECKED);
}
lv_obj_align(timeoutSwitch, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(timeoutSwitch, onTimeoutSwitch, LV_EVENT_VALUE_CHANGED, this);
auto* timeout_select_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
auto* timeout_value_label = lv_label_create(timeout_select_wrapper);
lv_label_set_text(timeout_value_label, "Timeout");
lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0);
timeoutDropdown = lv_dropdown_create(timeout_select_wrapper);
lv_dropdown_set_options(timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever");
lv_obj_align(timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, this);
// Initialize dropdown selection from settings
uint32_t ms = displaySettings.backlightTimeoutMs;
uint32_t idx = 2; // default 1 minute
if (ms == 15000) idx = 0;
else if (ms == 30000)
idx = 1;
else if (ms == 60000)
idx = 2;
else if (ms == 120000)
idx = 3;
else if (ms == 300000)
idx = 4;
else if (ms == 0)
idx = 5;
lv_dropdown_set_selected(timeoutDropdown, idx);
if (!displaySettings.backlightTimeoutEnabled) {
lv_obj_add_state(timeoutDropdown, LV_STATE_DISABLED);
}
// Screensaver type
auto* screensaver_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(screensaver_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(screensaver_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(screensaver_wrapper, 0, LV_STATE_DEFAULT);
auto* screensaver_label = lv_label_create(screensaver_wrapper);
lv_label_set_text(screensaver_label, "Screensaver");
lv_obj_align(screensaver_label, LV_ALIGN_LEFT_MID, 0, 0);
screensaverDropdown = lv_dropdown_create(screensaver_wrapper);
// Note: order correlates with settings::display::ScreensaverType enum order
lv_dropdown_set_options(screensaverDropdown, "None\nBouncing Balls\nMystify\nMatrix Rain\nStackChan");
lv_obj_align(screensaverDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(screensaverDropdown, onScreensaverChanged, LV_EVENT_VALUE_CHANGED, this);
lv_dropdown_set_selected(screensaverDropdown, static_cast<uint16_t>(displaySettings.screensaverType));
if (!displaySettings.backlightTimeoutEnabled) {
lv_obj_add_state(screensaverDropdown, LV_STATE_DISABLED);
}
}
}
void onHide(AppContext& app) override {
if (displaySettingsUpdated) {
// Dispatch it, so file IO doesn't block the UI
const settings::display::DisplaySettings settings_to_save = displaySettings;
getMainDispatcher().dispatch([settings_to_save] {
settings::display::save(settings_to_save);
// Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is
// giving up its thread for a save/resume cycle, or closing for good) whenever they changed.
void persistIfUpdated(Context& ctx) {
if (ctx.displaySettingsUpdated) {
// Dispatch it, so file IO doesn't block the UI
const settings::display::DisplaySettings settings_to_save = ctx.displaySettings;
getMainDispatcher().dispatch([settings_to_save] {
settings::display::save(settings_to_save);
#ifdef ESP_PLATFORM
// Notify DisplayIdle service to reload settings
auto displayIdle = service::displayidle::findService();
if (displayIdle) {
displayIdle->reloadSettings();
}
// Notify DisplayIdle service to reload settings
auto displayIdle = service::displayidle::findService();
if (displayIdle) {
displayIdle->reloadSettings();
}
#endif
});
});
}
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
persistIfUpdated(ctx);
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
};
extern const AppManifest manifest = {
.appId = "Display",
.appName = "Display",
.appIcon = LVGL_ICON_SHARED_DISPLAY_SETTINGS,
.appCategory = Category::Settings,
.createApp = create<KernelDisplayApp>
};
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "Display",
.name = "Display",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace tt::app::kerneldisplay
+203 -144
View File
@@ -3,16 +3,23 @@
#include <Tactility/Tactility.h>
#include <Tactility/settings/KeyboardSettings.h>
#include <Tactility/lvgl/Toolbar.h>
#include <lvgl/icons/shared.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/device.h>
#include <tactility/drivers/backlight.h>
#include <lvgl.h>
#include <lvgl/widgets/toolbar.h>
namespace tt::app::keyboardsettings {
extern const ::AppManifest manifest;
constexpr auto* TAG = "KeyboardSettings";
// Shared timeout values: 15s, 30s, 1m, 2m, 5m, Never (0)
@@ -35,157 +42,209 @@ static void applyKeyboardBacklight(bool enabled, uint8_t brightness) {
}
}
class KeyboardSettingsApp final : public App {
namespace {
struct Context {
uint32_t appInstanceId;
settings::keyboard::KeyboardSettings kbSettings;
bool updated = false;
lv_obj_t* switchBacklight = nullptr;
lv_obj_t* sliderBrightness = nullptr;
lv_obj_t* switchTimeoutEnable = nullptr;
lv_obj_t* timeoutDropdown = nullptr;
static void onBacklightSwitch(lv_event_t* e) {
auto* app = static_cast<KeyboardSettingsApp*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(app->switchBacklight, LV_STATE_CHECKED);
app->kbSettings.backlightEnabled = enabled;
app->updated = true;
if (app->sliderBrightness) {
if (enabled) lv_obj_clear_state(app->sliderBrightness, LV_STATE_DISABLED);
else lv_obj_add_state(app->sliderBrightness, LV_STATE_DISABLED);
}
applyKeyboardBacklight(enabled, app->kbSettings.backlightBrightness);
}
static void onBrightnessChanged(lv_event_t* e) {
auto* app = static_cast<KeyboardSettingsApp*>(lv_event_get_user_data(e));
int32_t v = lv_slider_get_value(app->sliderBrightness);
app->kbSettings.backlightBrightness = static_cast<uint8_t>(v);
app->updated = true;
if (app->kbSettings.backlightEnabled) {
applyKeyboardBacklight(true, app->kbSettings.backlightBrightness);
}
}
static void onTimeoutEnableSwitch(lv_event_t* e) {
auto* app = static_cast<KeyboardSettingsApp*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(app->switchTimeoutEnable, LV_STATE_CHECKED);
app->kbSettings.backlightTimeoutEnabled = enabled;
app->updated = true;
if (app->timeoutDropdown) {
if (enabled) {
lv_obj_clear_state(app->timeoutDropdown, LV_STATE_DISABLED);
} else {
lv_obj_add_state(app->timeoutDropdown, LV_STATE_DISABLED);
}
}
}
static void onTimeoutChanged(lv_event_t* event) {
auto* app = static_cast<KeyboardSettingsApp*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t idx = lv_dropdown_get_selected(dropdown);
if (idx < (sizeof(TIMEOUT_VALUES_MS) / sizeof(TIMEOUT_VALUES_MS[0]))) {
app->kbSettings.backlightTimeoutMs = TIMEOUT_VALUES_MS[idx];
app->updated = true;
}
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
kbSettings = settings::keyboard::loadOrGetDefault();
updated = false;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// Keyboard backlight toggle
auto* bl_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(bl_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(bl_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(bl_wrapper, 0, LV_STATE_DEFAULT);
auto* bl_label = lv_label_create(bl_wrapper);
lv_label_set_text(bl_label, "Keyboard backlight");
lv_obj_align(bl_label, LV_ALIGN_LEFT_MID, 0, 0);
switchBacklight = lv_switch_create(bl_wrapper);
if (kbSettings.backlightEnabled) lv_obj_add_state(switchBacklight, LV_STATE_CHECKED);
lv_obj_align(switchBacklight, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(switchBacklight, onBacklightSwitch, LV_EVENT_VALUE_CHANGED, this);
// Brightness slider
auto* br_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(br_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(br_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(br_wrapper, 0, LV_STATE_DEFAULT);
auto* br_label = lv_label_create(br_wrapper);
lv_label_set_text(br_label, "Brightness");
lv_obj_align(br_label, LV_ALIGN_LEFT_MID, 0, 0);
sliderBrightness = lv_slider_create(br_wrapper);
lv_obj_set_width(sliderBrightness, LV_PCT(50));
lv_obj_align(sliderBrightness, LV_ALIGN_RIGHT_MID, 0, 0);
lv_slider_set_range(sliderBrightness, 0, 255);
lv_slider_set_value(sliderBrightness, kbSettings.backlightBrightness, LV_ANIM_OFF);
if (!kbSettings.backlightEnabled) lv_obj_add_state(sliderBrightness, LV_STATE_DISABLED);
lv_obj_add_event_cb(sliderBrightness, onBrightnessChanged, LV_EVENT_VALUE_CHANGED, this);
// Backlight timeout enable
auto* to_enable_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(to_enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(to_enable_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(to_enable_wrapper, 0, LV_STATE_DEFAULT);
auto* to_enable_label = lv_label_create(to_enable_wrapper);
lv_label_set_text(to_enable_label, "Auto backlight off");
lv_obj_align(to_enable_label, LV_ALIGN_LEFT_MID, 0, 0);
switchTimeoutEnable = lv_switch_create(to_enable_wrapper);
if (kbSettings.backlightTimeoutEnabled) lv_obj_add_state(switchTimeoutEnable, LV_STATE_CHECKED);
lv_obj_align(switchTimeoutEnable, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(switchTimeoutEnable, onTimeoutEnableSwitch, LV_EVENT_VALUE_CHANGED, this);
auto* timeout_select_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
auto* timeout_value_label = lv_label_create(timeout_select_wrapper);
lv_label_set_text(timeout_value_label, "Timeout");
lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0);
// Backlight timeout value (seconds)
timeoutDropdown = lv_dropdown_create(timeout_select_wrapper);
lv_dropdown_set_options(timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever");
lv_obj_align(timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, this);
// Initialize dropdown selection from settings
lv_dropdown_set_selected(timeoutDropdown, timeoutMsToIndex(kbSettings.backlightTimeoutMs));
if (!kbSettings.backlightTimeoutEnabled) {
lv_obj_add_state(timeoutDropdown, LV_STATE_DISABLED);
}
}
void onHide(AppContext& app) override {
if (updated) {
const auto copy = kbSettings;
getMainDispatcher().dispatch([copy]{ settings::keyboard::save(copy); });
updated = false;
}
}
};
extern const AppManifest manifest = {
.appId = "KeyboardSettings",
.appName = "Keyboard",
.appIcon = LVGL_ICON_SHARED_KEYBOARD_ALT,
.appCategory = Category::Settings,
.createApp = create<KeyboardSettingsApp>
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onBacklightSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(ctx->switchBacklight, LV_STATE_CHECKED);
ctx->kbSettings.backlightEnabled = enabled;
ctx->updated = true;
if (ctx->sliderBrightness) {
if (enabled) lv_obj_clear_state(ctx->sliderBrightness, LV_STATE_DISABLED);
else lv_obj_add_state(ctx->sliderBrightness, LV_STATE_DISABLED);
}
applyKeyboardBacklight(enabled, ctx->kbSettings.backlightBrightness);
}
void onBrightnessChanged(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
int32_t v = lv_slider_get_value(ctx->sliderBrightness);
ctx->kbSettings.backlightBrightness = static_cast<uint8_t>(v);
ctx->updated = true;
if (ctx->kbSettings.backlightEnabled) {
applyKeyboardBacklight(true, ctx->kbSettings.backlightBrightness);
}
}
void onTimeoutEnableSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(ctx->switchTimeoutEnable, LV_STATE_CHECKED);
ctx->kbSettings.backlightTimeoutEnabled = enabled;
ctx->updated = true;
if (ctx->timeoutDropdown) {
if (enabled) {
lv_obj_clear_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
} else {
lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
}
}
}
void onTimeoutChanged(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
uint32_t idx = lv_dropdown_get_selected(dropdown);
if (idx < (sizeof(TIMEOUT_VALUES_MS) / sizeof(TIMEOUT_VALUES_MS[0]))) {
ctx->kbSettings.backlightTimeoutMs = TIMEOUT_VALUES_MS[idx];
ctx->updated = true;
}
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->kbSettings = settings::keyboard::loadOrGetDefault();
ctx->updated = false;
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, "Keyboard");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// Keyboard backlight toggle
auto* bl_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(bl_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(bl_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(bl_wrapper, 0, LV_STATE_DEFAULT);
auto* bl_label = lv_label_create(bl_wrapper);
lv_label_set_text(bl_label, "Keyboard backlight");
lv_obj_align(bl_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->switchBacklight = lv_switch_create(bl_wrapper);
if (ctx->kbSettings.backlightEnabled) lv_obj_add_state(ctx->switchBacklight, LV_STATE_CHECKED);
lv_obj_align(ctx->switchBacklight, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->switchBacklight, onBacklightSwitch, LV_EVENT_VALUE_CHANGED, ctx);
// Brightness slider
auto* br_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(br_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(br_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(br_wrapper, 0, LV_STATE_DEFAULT);
auto* br_label = lv_label_create(br_wrapper);
lv_label_set_text(br_label, "Brightness");
lv_obj_align(br_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->sliderBrightness = lv_slider_create(br_wrapper);
lv_obj_set_width(ctx->sliderBrightness, LV_PCT(50));
lv_obj_align(ctx->sliderBrightness, LV_ALIGN_RIGHT_MID, 0, 0);
lv_slider_set_range(ctx->sliderBrightness, 0, 255);
lv_slider_set_value(ctx->sliderBrightness, ctx->kbSettings.backlightBrightness, LV_ANIM_OFF);
if (!ctx->kbSettings.backlightEnabled) lv_obj_add_state(ctx->sliderBrightness, LV_STATE_DISABLED);
lv_obj_add_event_cb(ctx->sliderBrightness, onBrightnessChanged, LV_EVENT_VALUE_CHANGED, ctx);
// Backlight timeout enable
auto* to_enable_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(to_enable_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(to_enable_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(to_enable_wrapper, 0, LV_STATE_DEFAULT);
auto* to_enable_label = lv_label_create(to_enable_wrapper);
lv_label_set_text(to_enable_label, "Auto backlight off");
lv_obj_align(to_enable_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->switchTimeoutEnable = lv_switch_create(to_enable_wrapper);
if (ctx->kbSettings.backlightTimeoutEnabled) lv_obj_add_state(ctx->switchTimeoutEnable, LV_STATE_CHECKED);
lv_obj_align(ctx->switchTimeoutEnable, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->switchTimeoutEnable, onTimeoutEnableSwitch, LV_EVENT_VALUE_CHANGED, ctx);
auto* timeout_select_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(timeout_select_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(timeout_select_wrapper, 0, LV_STATE_DEFAULT);
auto* timeout_value_label = lv_label_create(timeout_select_wrapper);
lv_label_set_text(timeout_value_label, "Timeout");
lv_obj_align(timeout_value_label, LV_ALIGN_LEFT_MID, 0, 0);
// Backlight timeout value (seconds)
ctx->timeoutDropdown = lv_dropdown_create(timeout_select_wrapper);
lv_dropdown_set_options(ctx->timeoutDropdown, "15 seconds\n30 seconds\n1 minute\n2 minutes\n5 minutes\nNever");
lv_obj_align(ctx->timeoutDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->timeoutDropdown, onTimeoutChanged, LV_EVENT_VALUE_CHANGED, ctx);
// Initialize dropdown selection from settings
lv_dropdown_set_selected(ctx->timeoutDropdown, timeoutMsToIndex(ctx->kbSettings.backlightTimeoutMs));
if (!ctx->kbSettings.backlightTimeoutEnabled) {
lv_obj_add_state(ctx->timeoutDropdown, LV_STATE_DISABLED);
}
}
// Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is
// giving up its thread for a save/resume cycle, or closing for good) whenever they changed.
void persistIfUpdated(Context& ctx) {
if (ctx.updated) {
const auto copy = ctx.kbSettings;
getMainDispatcher().dispatch([copy]{ settings::keyboard::save(copy); });
ctx.updated = false;
}
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
persistIfUpdated(ctx);
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "KeyboardSettings",
.name = "Keyboard",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
}
+216 -178
View File
@@ -1,27 +1,31 @@
#include <Tactility/Tactility.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppPaths.h>
#include <Tactility/app/AppRegistration.h>
#include <Tactility/app/setup/Setup.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/settings/BootSettings.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <cstring>
#include <lvgl.h>
#include <lvgl.h>
#include <lvgl/icons/launcher.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/device.h>
#include <tactility/drivers/power_supply.h>
#include <tactility/log.h>
#include <Tactility/app/setup/Setup.h>
#include <Tactility/settings/BootSettings.h>
#include <Tactility/Tactility.h>
namespace tt::app::launcher {
constexpr auto* TAG = "Launcher";
static uint32_t getButtonPadding(UiDensity density, uint32_t buttonSize) {
namespace {
uint32_t getButtonPadding(UiDensity density, uint32_t buttonSize) {
if (density == LVGL_UI_DENSITY_COMPACT) {
return 0;
} else {
@@ -29,200 +33,234 @@ static uint32_t getButtonPadding(UiDensity density, uint32_t buttonSize) {
}
}
static int32_t computeButtonMargin(int32_t available_span, int32_t total_button_size) {
int32_t computeButtonMargin(int32_t available_span, int32_t total_button_size) {
const int32_t usable = std::max<int32_t>(0, available_span - (3 * total_button_size));
return std::min<int32_t>(usable / 16, total_button_size / 2);
}
class LauncherApp final : public App {
void onAppPressed(lv_event_t* e) {
auto* appId = static_cast<const char*>(lv_event_get_user_data(e));
uint32_t instance_id = 0;
app_manager_start(appId, &instance_id);
}
static lv_obj_t* createAppButton(lv_obj_t* parent, UiDensity uiDensity, const char* imageFile, const char* appId, int32_t itemMargin, bool isLandscape) {
const auto button_size = lvgl_get_launcher_icon_font_height();
const auto button_padding = getButtonPadding(uiDensity, button_size);
auto* apps_button = lv_button_create(parent);
lv_obj_t* createAppButton(lv_obj_t* parent, UiDensity uiDensity, const char* imageFile, const char* appId, int32_t itemMargin, bool isLandscape) {
const auto button_size = lvgl_get_launcher_icon_font_height();
const auto button_padding = getButtonPadding(uiDensity, button_size);
auto* apps_button = lv_button_create(parent);
lv_obj_set_style_pad_all(apps_button, static_cast<int32_t>(button_padding), LV_STATE_DEFAULT);
if (isLandscape) {
lv_obj_set_style_margin_hor(apps_button, itemMargin, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(apps_button, static_cast<int32_t>(button_padding), LV_STATE_DEFAULT);
if (isLandscape) {
lv_obj_set_style_margin_hor(apps_button, itemMargin, LV_STATE_DEFAULT);
} else {
lv_obj_set_style_margin_ver(apps_button, itemMargin, LV_STATE_DEFAULT);
}
lv_obj_set_style_shadow_width(apps_button, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(apps_button, 0, LV_STATE_DEFAULT);
// create the image first
auto* button_image = lv_image_create(apps_button);
lv_obj_set_style_text_font(button_image, lvgl_get_launcher_icon_font(), LV_STATE_DEFAULT);
lv_image_set_src(button_image, imageFile);
lv_obj_set_style_text_color(button_image, lv_theme_get_color_primary(button_image), LV_STATE_DEFAULT);
lv_obj_set_style_image_recolor(button_image, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT);
lv_obj_set_style_image_recolor_opa(button_image, LV_OPA_COVER, LV_STATE_DEFAULT);
// Ensure it's square (Material Symbols are slightly wider than tall)
lv_obj_set_size(button_image, button_size, button_size);
lv_obj_add_event_cb(apps_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)appId);
return apps_button;
}
bool shouldShowPowerButton() {
bool show_power_button = false;
device_for_each_of_type(&POWER_SUPPLY_TYPE, &show_power_button, [](Device* device, void* context) {
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
*static_cast<bool*>(context) = true;
return false; // stop iterating
} else {
lv_obj_set_style_margin_ver(apps_button, itemMargin, LV_STATE_DEFAULT);
return true; // continue iterating
}
});
return show_power_button;
}
lv_obj_set_style_shadow_width(apps_button, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(apps_button, 0, LV_STATE_DEFAULT);
void onButtonsWrapperResized(lv_event_t* e);
// create the image first
auto* button_image = lv_image_create(apps_button);
lv_obj_set_style_text_font(button_image, lvgl_get_launcher_icon_font(), LV_STATE_DEFAULT);
lv_image_set_src(button_image, imageFile);
lv_obj_set_style_text_color(button_image, lv_theme_get_color_primary(button_image), LV_STATE_DEFAULT);
lv_obj_set_style_image_recolor(button_image, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT);
lv_obj_set_style_image_recolor_opa(button_image, LV_OPA_COVER, LV_STATE_DEFAULT);
// The screen object outlives this window's own widgets (lvgl-window-manager deletes and
// recreates only the topmost window's widget on every app switch, not the screen itself), so
// the LV_EVENT_SIZE_CHANGED callback registered on it must be removed once buttons_wrapper is
// destroyed, to avoid a dangling user-data pointer the next time the display rotates while a
// different window is topmost.
void onButtonsWrapperDeleted(lv_event_t* e) {
auto* buttons_wrapper = lv_event_get_target_obj(e);
auto* screen = lv_obj_get_screen(buttons_wrapper);
lv_obj_remove_event_cb_with_user_data(screen, onButtonsWrapperResized, buttons_wrapper);
}
// Ensure it's square (Material Symbols are slightly wider than tall)
lv_obj_set_size(button_image, button_size, button_size);
// Re-applies the flex direction and per-button margins when the display orientation changes
// while the launcher is the visible window (these are decided once at createWidgets() based on
// the resolution at that time, so a later rotation needs this to catch up).
void onButtonsWrapperResized(lv_event_t* e) {
auto* buttons_wrapper = static_cast<lv_obj_t*>(lv_event_get_user_data(e));
const auto* display = lv_obj_get_display(buttons_wrapper);
lv_obj_add_event_cb(apps_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)appId);
const auto button_size = lvgl_get_launcher_icon_font_height();
const auto button_padding = getButtonPadding(lvgl_get_ui_density(), button_size);
const auto total_button_size = button_size + (button_padding * 2);
return apps_button;
const auto horizontal_px = lv_display_get_horizontal_resolution(display);
const auto vertical_px = lv_display_get_vertical_resolution(display);
const bool is_landscape_display = horizontal_px >= vertical_px;
const auto current_flow = lv_obj_get_style_flex_flow(buttons_wrapper, LV_PART_MAIN);
const bool was_landscape = current_flow == LV_FLEX_FLOW_ROW;
if (is_landscape_display == was_landscape) {
return;
}
static void onAppPressed(lv_event_t* e) {
auto* appId = static_cast<const char*>(lv_event_get_user_data(e));
start(appId);
lv_obj_set_flex_flow(buttons_wrapper, is_landscape_display ? LV_FLEX_FLOW_ROW : LV_FLEX_FLOW_COLUMN);
const int32_t margin = is_landscape_display
? computeButtonMargin(horizontal_px, total_button_size)
: computeButtonMargin(vertical_px, total_button_size);
const uint32_t child_count = lv_obj_get_child_count(buttons_wrapper);
for (uint32_t i = 0; i < child_count; i++) {
auto* button = lv_obj_get_child(buttons_wrapper, i);
lv_obj_set_style_margin_hor(button, is_landscape_display ? margin : 0, LV_STATE_DEFAULT);
lv_obj_set_style_margin_ver(button, is_landscape_display ? 0 : margin, LV_STATE_DEFAULT);
}
}
void createWidgets(lv_obj_t* parent, void*) {
auto* buttons_wrapper = lv_obj_create(parent);
auto ui_density = lvgl_get_ui_density();
const auto button_size = lvgl_get_launcher_icon_font_height();
const auto button_padding = getButtonPadding(ui_density, button_size);
const auto total_button_size = button_size + (button_padding * 2);
lv_obj_align(buttons_wrapper, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_size(buttons_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_border_width(buttons_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_grow(buttons_wrapper, 1);
// Fix for button selection
lv_obj_set_style_pad_all(buttons_wrapper, 6, LV_STATE_DEFAULT);
const auto* display = lv_obj_get_display(parent);
const auto horizontal_px = lv_display_get_horizontal_resolution(display);
const auto vertical_px = lv_display_get_vertical_resolution(display);
const bool is_landscape_display = horizontal_px >= vertical_px;
if (is_landscape_display) {
lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_ROW);
} else {
lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_COLUMN);
}
static bool shouldShowPowerButton() {
bool show_power_button = false;
device_for_each_of_type(&POWER_SUPPLY_TYPE, &show_power_button, [](Device* device, void* context) {
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
*static_cast<bool*>(context) = true;
return false; // stop iterating
} else {
return true; // continue iterating
}
});
return show_power_button;
const int32_t margin = is_landscape_display
? computeButtonMargin(lv_display_get_horizontal_resolution(display), total_button_size)
: computeButtonMargin(lv_display_get_vertical_resolution(display), total_button_size);
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_APPS, "AppList", margin, is_landscape_display);
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_FOLDER, "Files", margin, is_landscape_display);
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_SETTINGS, "Settings", margin, is_landscape_display);
// The launcher's container is several levels below the screen, and LVGL only sends
// LV_EVENT_SIZE_CHANGED to the screen object itself on a resolution change - so the
// handler is attached there, with buttons_wrapper passed through as user data.
lv_obj_add_event_cb(lv_obj_get_screen(parent), onButtonsWrapperResized, LV_EVENT_SIZE_CHANGED, buttons_wrapper);
lv_obj_add_event_cb(buttons_wrapper, onButtonsWrapperDeleted, LV_EVENT_DELETE, nullptr);
// Some devices (e.g. T-Lora Pager) have no other way to power off, so the
// button stays in the launcher; the confirmation flow lives in the PowerOff app.
if (shouldShowPowerButton()) {
auto* power_button = lv_button_create(parent);
lv_obj_set_style_pad_all(power_button, 8, 0);
lv_obj_align(power_button, LV_ALIGN_BOTTOM_MID, 0, -10);
lv_obj_add_event_cb(power_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)"PowerOff");
lv_obj_set_style_shadow_width(power_button, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(power_button, 0, LV_PART_MAIN);
auto* power_label = lv_label_create(power_button);
lv_label_set_text(power_label, LV_SYMBOL_POWER);
lv_obj_set_style_text_color(power_label, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT);
}
}
// The screen object outlives the launcher's views (it's recreated by GuiService::redraw()
// via lv_obj_clean() on every app switch), so the LV_EVENT_SIZE_CHANGED callback registered
// on it must be removed once buttons_wrapper is destroyed, to avoid a dangling user-data
// pointer on the next rotation while a different app is visible.
static void onButtonsWrapperDeleted(lv_event_t* e) {
auto* buttons_wrapper = lv_event_get_target_obj(e);
auto* screen = lv_obj_get_screen(buttons_wrapper);
lv_obj_remove_event_cb_with_user_data(screen, onButtonsWrapperResized, buttons_wrapper);
}
// Re-applies the flex direction and per-button margins when the display orientation
// changes while the launcher is the visible app (these are decided once at onShow()
// based on the resolution at that time, so a later rotation needs this to catch up).
static void onButtonsWrapperResized(lv_event_t* e) {
auto* buttons_wrapper = static_cast<lv_obj_t*>(lv_event_get_user_data(e));
const auto* display = lv_obj_get_display(buttons_wrapper);
const auto button_size = lvgl_get_launcher_icon_font_height();
const auto button_padding = getButtonPadding(lvgl_get_ui_density(), button_size);
const auto total_button_size = button_size + (button_padding * 2);
const auto horizontal_px = lv_display_get_horizontal_resolution(display);
const auto vertical_px = lv_display_get_vertical_resolution(display);
const bool is_landscape_display = horizontal_px >= vertical_px;
const auto current_flow = lv_obj_get_style_flex_flow(buttons_wrapper, LV_PART_MAIN);
const bool was_landscape = current_flow == LV_FLEX_FLOW_ROW;
if (is_landscape_display == was_landscape) {
return;
void runAutoStart() {
settings::BootSettings boot_properties;
if (
// Auto-start due to built-in requirement
strcmp(CONFIG_TT_AUTO_START_APP_ID, "") != 0 &&
app_manager_find_manifest(CONFIG_TT_AUTO_START_APP_ID) != nullptr
) {
LOG_I(TAG, "Starting %s", CONFIG_TT_AUTO_START_APP_ID);
uint32_t app_launch_id;
app_manager_start(CONFIG_TT_AUTO_START_APP_ID, &app_launch_id);
} else if (
// Auto-start due to user configuration
settings::loadBootSettings(boot_properties) &&
!boot_properties.autoStartAppId.empty() &&
app_manager_find_manifest(boot_properties.autoStartAppId.c_str()) != nullptr
) {
LOG_I(TAG, "Starting %s", boot_properties.autoStartAppId.c_str());
uint32_t app_launch_id;
app_manager_start(boot_properties.autoStartAppId.c_str(), &app_launch_id);
} else {
// No auto-start, consider running system setup
if (!setup::isCompleted()) {
setup::start();
}
}
}
lv_obj_set_flex_flow(buttons_wrapper, is_landscape_display ? LV_FLEX_FLOW_ROW : LV_FLEX_FLOW_COLUMN);
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
runAutoStart();
const int32_t margin = is_landscape_display
? computeButtonMargin(horizontal_px, total_button_size)
: computeButtonMargin(vertical_px, total_button_size);
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
const uint32_t child_count = lv_obj_get_child_count(buttons_wrapper);
for (uint32_t i = 0; i < child_count; i++) {
auto* button = lv_obj_get_child(buttons_wrapper, i);
lv_obj_set_style_margin_hor(button, is_landscape_display ? margin : 0, LV_STATE_DEFAULT);
lv_obj_set_style_margin_ver(button, is_landscape_display ? 0 : margin, LV_STATE_DEFAULT);
WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr);
// The launcher is meant to stay resident (it's the home screen) - it only gives up its
// thread when app-module's scheduler asks it to (e.g. another new-model app is started).
while (true) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
if (event.type == APP_EVENT_CLOSE) {
app_manager_finish(appInstanceId);
break;
}
}
public:
void onCreate(AppContext& app) override {
settings::BootSettings boot_properties;
if (
// Auto-start due to built-in requirement
strcmp(CONFIG_TT_AUTO_START_APP_ID, "") != 0 &&
findAppManifestById(CONFIG_TT_AUTO_START_APP_ID) != nullptr
) {
LOG_I(TAG, "Starting %s", CONFIG_TT_AUTO_START_APP_ID);
start(CONFIG_TT_AUTO_START_APP_ID);
} else if (
// Auto-start due to user configuration
settings::loadBootSettings(boot_properties) &&
!boot_properties.autoStartAppId.empty() &&
findAppManifestById(boot_properties.autoStartAppId) != nullptr
) {
LOG_I(TAG, "Starting %s", boot_properties.autoStartAppId.c_str());
start(boot_properties.autoStartAppId);
} else {
// No auto-start, consider running system setup
if (!setup::isCompleted()) {
setup::start();
}
}
}
void onShow(AppContext& app, lv_obj_t* parent) override {
auto* buttons_wrapper = lv_obj_create(parent);
auto ui_density = lvgl_get_ui_density();
const auto button_size = lvgl_get_launcher_icon_font_height();
const auto button_padding = getButtonPadding(ui_density, button_size);
const auto total_button_size = button_size + (button_padding * 2);
lv_obj_align(buttons_wrapper, LV_ALIGN_CENTER, 0, 0);
lv_obj_set_size(buttons_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_border_width(buttons_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_grow(buttons_wrapper, 1);
// Fix for button selection
lv_obj_set_style_pad_all(buttons_wrapper, 6, LV_STATE_DEFAULT);
const auto* display = lv_obj_get_display(parent);
const auto horizontal_px = lv_display_get_horizontal_resolution(display);
const auto vertical_px = lv_display_get_vertical_resolution(display);
const bool is_landscape_display = horizontal_px >= vertical_px;
if (is_landscape_display) {
lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_ROW);
} else {
lv_obj_set_flex_flow(buttons_wrapper, LV_FLEX_FLOW_COLUMN);
}
const int32_t margin = is_landscape_display
? computeButtonMargin(lv_display_get_horizontal_resolution(display), total_button_size)
: computeButtonMargin(lv_display_get_vertical_resolution(display), total_button_size);
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_APPS, "AppList", margin, is_landscape_display);
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_FOLDER, "Files", margin, is_landscape_display);
createAppButton(buttons_wrapper, ui_density, LVGL_ICON_LAUNCHER_SETTINGS, "Settings", margin, is_landscape_display);
// The launcher's container is several levels below the screen, and LVGL only sends
// LV_EVENT_SIZE_CHANGED to the screen object itself on a resolution change - so the
// handler is attached there, with buttons_wrapper passed through as user data.
lv_obj_add_event_cb(lv_obj_get_screen(parent), onButtonsWrapperResized, LV_EVENT_SIZE_CHANGED, buttons_wrapper);
lv_obj_add_event_cb(buttons_wrapper, onButtonsWrapperDeleted, LV_EVENT_DELETE, nullptr);
// Some devices (e.g. T-Lora Pager) have no other way to power off, so the
// button stays in the launcher; the confirmation flow lives in the PowerOff app.
if (shouldShowPowerButton()) {
auto* power_button = lv_button_create(parent);
lv_obj_set_style_pad_all(power_button, 8, 0);
lv_obj_align(power_button, LV_ALIGN_BOTTOM_MID, 0, -10);
lv_obj_add_event_cb(power_button, onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)"PowerOff");
lv_obj_set_style_shadow_width(power_button, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(power_button, 0, LV_PART_MAIN);
auto* power_label = lv_label_create(power_button);
lv_label_set_text(power_label, LV_SYMBOL_POWER);
lv_obj_set_style_text_color(power_label, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT);
}
}
};
extern const AppManifest manifest = {
.appId = "Launcher",
.appName = "Launcher",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<LauncherApp>
};
LaunchId start() {
return app::start(manifest.appId);
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "Launcher",
.name = "Launcher",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
// Kept for Tactility/Private/Tactility/app/launcher/Launcher.h's existing declaration (still
// used by the old, unconverted CrashDiagnostics app to return to the launcher after a crash).
uint32_t start() {
uint32_t instance_id = 0;
app_manager_start(manifest.id, &instance_id);
return instance_id;
}
} // namespace
@@ -3,12 +3,16 @@
#include <Tactility/RecursiveMutex.h>
#include <Tactility/StringUtils.h>
#include <Tactility/app/localesettings/TextResources.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/settings/Language.h>
#include <Tactility/settings/SystemSettings.h>
#include <lvgl/icons/shared.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/widgets/toolbar.h>
#include <lvgl.h>
#include <map>
@@ -23,112 +27,159 @@ constexpr auto* TEXT_RESOURCE_PATH = "/system/app/LocaleSettings/i18n";
constexpr auto* TEXT_RESOURCE_PATH = "system/app/LocaleSettings/i18n";
#endif
extern const AppManifest manifest;
extern const ::AppManifest manifest;
class LocaleSettingsApp final : public App {
namespace {
struct Context {
uint32_t appInstanceId;
tt::i18n::TextResources textResources = tt::i18n::TextResources(TEXT_RESOURCE_PATH);
RecursiveMutex mutex;
lv_obj_t* languageDropdown = nullptr;
bool settingsUpdated = false;
std::map<settings::Language, std::string> languageMap;
};
std::string getLanguageOptions() const {
std::vector<std::string> items;
for (int i = 0; i < static_cast<int>(settings::Language::count); i++) {
switch (static_cast<settings::Language>(i)) {
case settings::Language::en_GB:
items.push_back(textResources[i18n::Text::EN_GB]);
break;
case settings::Language::en_US:
items.push_back(textResources[i18n::Text::EN_US]);
break;
case settings::Language::fr_FR:
items.push_back(textResources[i18n::Text::FR_FR]);
break;
case settings::Language::nl_BE:
items.push_back(textResources[i18n::Text::NL_BE]);
break;
case settings::Language::nl_NL:
items.push_back(textResources[i18n::Text::NL_NL]);
break;
case settings::Language::count:
break;
}
std::string getLanguageOptions(Context* ctx) {
std::vector<std::string> items;
for (int i = 0; i < static_cast<int>(settings::Language::count); i++) {
switch (static_cast<settings::Language>(i)) {
case settings::Language::en_GB:
items.push_back(ctx->textResources[i18n::Text::EN_GB]);
break;
case settings::Language::en_US:
items.push_back(ctx->textResources[i18n::Text::EN_US]);
break;
case settings::Language::fr_FR:
items.push_back(ctx->textResources[i18n::Text::FR_FR]);
break;
case settings::Language::nl_BE:
items.push_back(ctx->textResources[i18n::Text::NL_BE]);
break;
case settings::Language::nl_NL:
items.push_back(ctx->textResources[i18n::Text::NL_NL]);
break;
case settings::Language::count:
break;
}
}
return string::join(items, "\n");
}
void updateViews(Context* ctx) {
ctx->textResources.load();
std::string language_options = getLanguageOptions(ctx);
lv_dropdown_set_options(ctx->languageDropdown, language_options.c_str());
lv_dropdown_set_selected(ctx->languageDropdown, static_cast<uint32_t>(settings::getLanguage()));
}
void onLanguageSet(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
auto index = lv_dropdown_get_selected(dropdown);
auto language = static_cast<settings::Language>(index);
settings::setLanguage(language);
updateViews(ctx);
}
// Preserved from the pre-conversion code as-is: declared but never wired to any widget there
// either, so this has always been dead code (kept verbatim rather than dropped, since removing
// it would be a functional judgment call outside the scope of this lifecycle-only conversion).
[[maybe_unused]] void onRegionChanged(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->settingsUpdated = true;
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->textResources.load();
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, "Region & Language");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// Language
auto* language_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(language_wrapper, LV_PCT(100));
lv_obj_set_height(language_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(language_wrapper, 8, 0);
lv_obj_set_style_border_width(language_wrapper, 0, 0);
auto* languageLabel = lv_label_create(language_wrapper);
lv_label_set_text(languageLabel, ctx->textResources[i18n::Text::LANGUAGE].c_str());
lv_obj_align(languageLabel, LV_ALIGN_LEFT_MID, 4, 0);
ctx->languageDropdown = lv_dropdown_create(language_wrapper);
lv_obj_set_width(ctx->languageDropdown, 150);
lv_obj_align(ctx->languageDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
std::string language_options = getLanguageOptions(ctx);
lv_dropdown_set_options(ctx->languageDropdown, language_options.c_str());
lv_dropdown_set_selected(ctx->languageDropdown, static_cast<uint32_t>(settings::getLanguage()));
lv_obj_add_event_cb(ctx->languageDropdown, onLanguageSet, LV_EVENT_VALUE_CHANGED, ctx);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx;
ctx.appInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
return string::join(items, "\n");
}
void updateViews() {
textResources.load();
window_manager_remove(window);
app_event_unsubscribe(&sub);
std::string language_options = getLanguageOptions();
lv_dropdown_set_options(languageDropdown, language_options.c_str());
lv_dropdown_set_selected(languageDropdown, static_cast<uint32_t>(settings::getLanguage()));
}
static void onLanguageSet(lv_event_t* event) {
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
auto index = lv_dropdown_get_selected(dropdown);
auto language = static_cast<settings::Language>(index);
settings::setLanguage(language);
auto* self = static_cast<LocaleSettingsApp*>(lv_event_get_user_data(event));
self->updateViews();
}
static void onRegionChanged(lv_event_t* event) {
auto* self = static_cast<LocaleSettingsApp*>(lv_event_get_user_data(event));
self->settingsUpdated = true;
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
textResources.load();
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// Language
auto* language_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(language_wrapper, LV_PCT(100));
lv_obj_set_height(language_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(language_wrapper, 8, 0);
lv_obj_set_style_border_width(language_wrapper, 0, 0);
auto* languageLabel = lv_label_create(language_wrapper);
lv_label_set_text(languageLabel, textResources[i18n::Text::LANGUAGE].c_str());
lv_obj_align(languageLabel, LV_ALIGN_LEFT_MID, 4, 0);
languageDropdown = lv_dropdown_create(language_wrapper);
lv_obj_set_width(languageDropdown, 150);
lv_obj_align(languageDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
std::string language_options = getLanguageOptions();
lv_dropdown_set_options(languageDropdown, language_options.c_str());
lv_dropdown_set_selected(languageDropdown, static_cast<uint32_t>(settings::getLanguage()));
lv_obj_add_event_cb(languageDropdown, onLanguageSet, LV_EVENT_VALUE_CHANGED, this);
}
};
extern const AppManifest manifest = {
.appId = "LocaleSettings",
.appName = "Region & Language",
.appIcon = LVGL_ICON_SHARED_LANGUAGE,
.appCategory = Category::Settings,
.createApp = create<LocaleSettingsApp>
};
LaunchId start() {
return app::start(manifest.appId);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "LocaleSettings",
.name = "Region & Language",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace tt::app::localesettings
+238 -208
View File
@@ -1,228 +1,258 @@
#include "lvgl/lvgl.h"
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/notes/Notes.h>
#include <Tactility/app/fileselection/FileSelection.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/file/File.h>
#include <lvgl/icons/shared.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/log.h>
namespace tt::app::notes {
constexpr auto* TAG = "Notes";
constexpr auto* NOTES_FILE_ARGUMENT = "file";
class NotesApp final : public App {
extern const ::AppManifest manifest;
lv_obj_t* uiCurrentFileName;
lv_obj_t* uiDropDownMenu;
lv_obj_t* uiNoteText;
namespace {
struct Context {
uint32_t appInstanceId;
lv_obj_t* uiCurrentFileName = nullptr;
lv_obj_t* uiDropDownMenu = nullptr;
lv_obj_t* uiNoteText = nullptr;
std::string filePath;
std::string saveBuffer;
LaunchId loadFileLaunchId = 0;
LaunchId saveFileLaunchId = 0;
#pragma region Main_Events_Functions
void appNotesEventCb(lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
lv_obj_t* obj = lv_event_get_target_obj(e);
if (code == LV_EVENT_VALUE_CHANGED) {
if (obj == uiDropDownMenu) {
switch (lv_dropdown_get_selected(obj)) {
case 0: // New
resetFileContent();
break;
case 1: // Save
if (!filePath.empty()) {
lvgl_lock();
saveBuffer = lv_textarea_get_text(uiNoteText);
lvgl_unlock();
saveFile(filePath);
}
break;
case 2: // Save as...
lvgl_lock();
saveBuffer = lv_textarea_get_text(uiNoteText);
lvgl_unlock();
saveFileLaunchId = fileselection::startForExistingOrNewFile();
LOG_I(TAG, "launched with id %u", saveFileLaunchId);
break;
case 3: // Load
loadFileLaunchId = fileselection::startForExistingFile();
LOG_I(TAG, "launched with id %u", loadFileLaunchId);
break;
}
} else {
auto* cont = lv_event_get_current_target_obj(e);
if (obj == cont) return;
if (lv_obj_get_child(cont, 1)) {
saveFileLaunchId = fileselection::startForExistingOrNewFile();
LOG_I(TAG, "launched with id %u", saveFileLaunchId);
} else { //Reset
resetFileContent();
}
}
}
}
void resetFileContent() {
lv_textarea_set_text(uiNoteText, "");
filePath = "";
saveBuffer = "";
lv_label_set_text(uiCurrentFileName, "Untitled");
}
#pragma region Open_Events_Functions
void openFile(const std::string& path) {
// We might be reading from the SD card, which could share a SPI bus with other devices (display)
file::FileMutexGuard guard(path);
auto data = file::readString(path);
if (data != nullptr) {
lvgl_lock();
lv_textarea_set_text(uiNoteText, reinterpret_cast<const char*>(data.get()));
lv_label_set_text(uiCurrentFileName, path.c_str());
lvgl_unlock();
filePath = path;
LOG_I(TAG, "Loaded from %s", path.c_str());
}
}
bool saveFile(const std::string& path) {
// We might be writing to SD card, which could share a SPI bus with other devices (display)
bool result = false;
{
file::FileMutexGuard guard(path);
if (file::writeString(path, saveBuffer.c_str())) {
LOG_I(TAG, "Saved to %s", path.c_str());
filePath = path;
result = true;
}
}
return result;
}
#pragma endregion Open_Events_Functions
void onCreate(AppContext& appContext) override {
auto parameters = appContext.getParameters();
std::string file_path;
if (parameters != nullptr && parameters->optString(NOTES_FILE_ARGUMENT, file_path)) {
if (!file_path.empty()) {
filePath = file_path;
}
}
}
void onShow(AppContext& context, lv_obj_t* parent) override {
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lv_obj_t* toolbar = lvgl::toolbar_create(parent, context);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
uiDropDownMenu = lv_dropdown_create(toolbar);
lv_dropdown_set_options(uiDropDownMenu, LV_SYMBOL_FILE " New File\n" LV_SYMBOL_SAVE " Save\n" LV_SYMBOL_SAVE " Save As...\n" LV_SYMBOL_DIRECTORY " Open File");
lv_dropdown_set_text(uiDropDownMenu, "Menu");
lv_dropdown_set_symbol(uiDropDownMenu, LV_SYMBOL_DOWN);
lv_dropdown_set_selected_highlight(uiDropDownMenu, false);
lv_obj_align(uiDropDownMenu, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(uiDropDownMenu,
[](lv_event_t* e) {
auto *self = static_cast<NotesApp *>(lv_event_get_user_data(e));
self->appNotesEventCb(e);
},
LV_EVENT_VALUE_CHANGED,
this
);
lv_obj_t* wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_height(wrapper, LV_PCT(100));
lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN);
lv_obj_set_style_pad_row(wrapper, 0, LV_PART_MAIN);
lv_obj_set_style_border_width(wrapper, 0, LV_PART_MAIN);
lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE);
uiNoteText = lv_textarea_create(wrapper);
lv_obj_set_width(uiNoteText, LV_PCT(100));
lv_obj_set_height(uiNoteText, LV_PCT(86));
lv_textarea_set_password_mode(uiNoteText, false);
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
lv_obj_set_style_bg_color(uiNoteText, lv_color_hex(0x262626), LV_PART_MAIN);
}
lv_textarea_set_placeholder_text(uiNoteText, "Notes...");
lv_obj_t* footer = lv_obj_create(wrapper);
lv_obj_set_flex_flow(footer, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(footer, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) {
lv_obj_set_style_bg_color(footer, lv_color_hex(0xEEEEEE), LV_PART_MAIN);
lv_obj_set_style_border_width(footer, 1, LV_PART_MAIN);
lv_obj_set_style_border_color(footer, lv_theme_get_color_secondary(footer), LV_PART_MAIN);
lv_obj_set_style_border_side(footer, LV_BORDER_SIDE_TOP, LV_PART_MAIN);
} else {
lv_obj_set_style_bg_color(footer, lv_color_hex(0x262626), LV_PART_MAIN);
lv_obj_set_style_border_width(footer, 0, LV_PART_MAIN);
}
lv_obj_set_width(footer, LV_PCT(100));
lv_obj_set_height(footer, LV_PCT(14));
lv_obj_set_style_pad_all(footer, 0, LV_PART_MAIN);
lv_obj_remove_flag(footer, LV_OBJ_FLAG_SCROLLABLE);
uiCurrentFileName = lv_label_create(footer);
lv_label_set_long_mode(uiCurrentFileName, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
lv_obj_set_width(uiCurrentFileName, LV_SIZE_CONTENT);
lv_obj_set_height(uiCurrentFileName, LV_SIZE_CONTENT);
lv_label_set_text(uiCurrentFileName, "Untitled");
lv_obj_align(uiCurrentFileName, LV_ALIGN_CENTER, 0, 0);
if (!filePath.empty()) {
openFile(filePath);
}
}
void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> resultData) override {
LOG_I(TAG, "Result for launch id %u", launchId);
if (launchId == loadFileLaunchId) {
loadFileLaunchId = 0;
if (result == Result::Ok && resultData != nullptr) {
auto path = fileselection::getResultPath(*resultData);
openFile(path);
}
} else if (launchId == saveFileLaunchId) {
saveFileLaunchId = 0;
if (result == Result::Ok && resultData != nullptr) {
auto path = fileselection::getResultPath(*resultData);
// Must re-open file, because UI was cleared after opening other app
if (saveFile(path)) {
openFile(path);
}
}
}
}
uint32_t loadFileLaunchId = 0;
uint32_t saveFileLaunchId = 0;
};
extern const AppManifest manifest = {
.appId = "Notes",
.appName = "Notes",
.appIcon = LVGL_ICON_SHARED_EDIT_NOTE,
.createApp = create<NotesApp>
};
LaunchId start(const std::string& filePath) {
auto parameters = std::make_shared<Bundle>();
parameters->putString(NOTES_FILE_ARGUMENT, filePath);
return app::start(manifest.appId, parameters);
void resetFileContent(Context* ctx) {
lv_textarea_set_text(ctx->uiNoteText, "");
ctx->filePath = "";
ctx->saveBuffer = "";
lv_label_set_text(ctx->uiCurrentFileName, "Untitled");
}
} // namespace tt::app::notes
void openFile(Context* ctx, const std::string& path) {
// We might be reading from the SD card, which could share a SPI bus with other devices (display)
file::FileMutexGuard guard(path);
auto data = file::readString(path);
if (data != nullptr) {
lvgl_lock();
lv_textarea_set_text(ctx->uiNoteText, reinterpret_cast<const char*>(data.get()));
lv_label_set_text(ctx->uiCurrentFileName, path.c_str());
lvgl_unlock();
ctx->filePath = path;
LOG_I(TAG, "Loaded from %s", path.c_str());
}
}
bool saveFile(Context* ctx, const std::string& path) {
// We might be writing to SD card, which could share a SPI bus with other devices (display)
bool result = false;
{
file::FileMutexGuard guard(path);
if (file::writeString(path, ctx->saveBuffer.c_str())) {
LOG_I(TAG, "Saved to %s", path.c_str());
ctx->filePath = path;
result = true;
}
}
return result;
}
void appNotesEventCb(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
lv_event_code_t code = lv_event_get_code(e);
lv_obj_t* obj = lv_event_get_target_obj(e);
if (code == LV_EVENT_VALUE_CHANGED) {
if (obj == ctx->uiDropDownMenu) {
switch (lv_dropdown_get_selected(obj)) {
case 0: // New
resetFileContent(ctx);
break;
case 1: // Save
if (!ctx->filePath.empty()) {
lvgl_lock();
ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText);
lvgl_unlock();
saveFile(ctx, ctx->filePath);
}
break;
case 2: // Save as...
lvgl_lock();
ctx->saveBuffer = lv_textarea_get_text(ctx->uiNoteText);
lvgl_unlock();
ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId);
LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId);
break;
case 3: // Load
ctx->loadFileLaunchId = fileselection::startForExistingFile(ctx->appInstanceId);
LOG_I(TAG, "launched with id %u", ctx->loadFileLaunchId);
break;
}
} else {
auto* cont = lv_event_get_current_target_obj(e);
if (obj == cont) return;
if (lv_obj_get_child(cont, 1)) {
ctx->saveFileLaunchId = fileselection::startForExistingOrNewFile(ctx->appInstanceId);
LOG_I(TAG, "launched with id %u", ctx->saveFileLaunchId);
} else { //Reset
resetFileContent(ctx);
}
}
}
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Notes");
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
ctx->uiDropDownMenu = lv_dropdown_create(toolbar);
lv_dropdown_set_options(ctx->uiDropDownMenu, LV_SYMBOL_FILE " New File\n" LV_SYMBOL_SAVE " Save\n" LV_SYMBOL_SAVE " Save As...\n" LV_SYMBOL_DIRECTORY " Open File");
lv_dropdown_set_text(ctx->uiDropDownMenu, "Menu");
lv_dropdown_set_symbol(ctx->uiDropDownMenu, LV_SYMBOL_DOWN);
lv_dropdown_set_selected_highlight(ctx->uiDropDownMenu, false);
lv_obj_align(ctx->uiDropDownMenu, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->uiDropDownMenu, appNotesEventCb, LV_EVENT_VALUE_CHANGED, ctx);
lv_obj_t* wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_height(wrapper, LV_PCT(100));
lv_obj_set_style_pad_all(wrapper, 0, LV_PART_MAIN);
lv_obj_set_style_pad_row(wrapper, 0, LV_PART_MAIN);
lv_obj_set_style_border_width(wrapper, 0, LV_PART_MAIN);
lv_obj_remove_flag(wrapper, LV_OBJ_FLAG_SCROLLABLE);
ctx->uiNoteText = lv_textarea_create(wrapper);
lv_obj_set_width(ctx->uiNoteText, LV_PCT(100));
lv_obj_set_height(ctx->uiNoteText, LV_PCT(86));
lv_textarea_set_password_mode(ctx->uiNoteText, false);
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
lv_obj_set_style_bg_color(ctx->uiNoteText, lv_color_hex(0x262626), LV_PART_MAIN);
}
lv_textarea_set_placeholder_text(ctx->uiNoteText, "Notes...");
lv_obj_t* footer = lv_obj_create(wrapper);
lv_obj_set_flex_flow(footer, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(footer, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) {
lv_obj_set_style_bg_color(footer, lv_color_hex(0xEEEEEE), LV_PART_MAIN);
lv_obj_set_style_border_width(footer, 1, LV_PART_MAIN);
lv_obj_set_style_border_color(footer, lv_theme_get_color_secondary(footer), LV_PART_MAIN);
lv_obj_set_style_border_side(footer, LV_BORDER_SIDE_TOP, LV_PART_MAIN);
} else {
lv_obj_set_style_bg_color(footer, lv_color_hex(0x262626), LV_PART_MAIN);
lv_obj_set_style_border_width(footer, 0, LV_PART_MAIN);
}
lv_obj_set_width(footer, LV_PCT(100));
lv_obj_set_height(footer, LV_PCT(14));
lv_obj_set_style_pad_all(footer, 0, LV_PART_MAIN);
lv_obj_remove_flag(footer, LV_OBJ_FLAG_SCROLLABLE);
ctx->uiCurrentFileName = lv_label_create(footer);
lv_label_set_long_mode(ctx->uiCurrentFileName, LV_LABEL_LONG_MODE_SCROLL_CIRCULAR);
lv_obj_set_width(ctx->uiCurrentFileName, LV_SIZE_CONTENT);
lv_obj_set_height(ctx->uiCurrentFileName, LV_SIZE_CONTENT);
lv_label_set_text(ctx->uiCurrentFileName, "Untitled");
lv_obj_align(ctx->uiCurrentFileName, LV_ALIGN_CENTER, 0, 0);
if (!ctx->filePath.empty()) {
openFile(ctx, ctx->filePath);
}
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
if (argc > 0 && argv[0][0] != '\0') {
ctx.filePath = argv[0];
}
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
case APP_EVENT_RESULT:
LOG_I(TAG, "Result for launch id %u", event.result.launch_id);
if (event.result.launch_id == ctx.loadFileLaunchId) {
ctx.loadFileLaunchId = 0;
if (event.result.result == 0 /* Ok */) {
auto path = fileselection::getLastPath();
if (!path.empty()) {
openFile(&ctx, path);
}
}
} else if (event.result.launch_id == ctx.saveFileLaunchId) {
ctx.saveFileLaunchId = 0;
if (event.result.result == 0 /* Ok */) {
auto path = fileselection::getLastPath();
// Must re-open file, because the UI was cleared after opening the dialog.
if (!path.empty() && saveFile(&ctx, path)) {
openFile(&ctx, path);
}
}
}
app_manager_stop(event.result.launch_id);
break;
default:
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
void start(const std::string& filePath) {
const char* argv[] = { filePath.c_str() };
uint32_t instanceId = 0;
app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId);
}
extern const ::AppManifest manifest = {
.id = "Notes",
.name = "Notes",
.category = APP_CATEGORY_USER,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace tt::app::notes
+240 -221
View File
@@ -1,15 +1,18 @@
#include <Tactility/app/AppContext.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/Timer.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/device.h>
#include <tactility/drivers/power_supply.h>
#include <tactility/time.h>
#include <lvgl/lvgl.h>
#include <lvgl/icons/shared.h>
#include <lvgl/widgets/toolbar.h>
#include <vector>
@@ -17,28 +20,16 @@ namespace tt::app::power {
#define TAG "power"
extern const AppManifest manifest;
class PowerApp;
/** Returns the app data if the app is active. Note that this could clash if the same app is started twice and a background thread is slow. */
std::shared_ptr<PowerApp> optApp() {
auto appContext = getCurrentAppContext();
if (appContext != nullptr && appContext->getManifest().appId == manifest.appId) {
return std::static_pointer_cast<PowerApp>(appContext->getApp());
} else {
return nullptr;
}
}
extern const ::AppManifest manifest;
namespace {
constexpr PowerSupplyProperty DISPLAYED_PROPERTIES[] = {
POWER_SUPPLY_PROP_IS_CHARGING,
POWER_SUPPLY_PROP_VOLTAGE,
POWER_SUPPLY_PROP_CAPACITY,
POWER_SUPPLY_PROP_CURRENT,
};
} // namespace
struct PropertyWidget {
PowerSupplyProperty property;
@@ -52,212 +43,240 @@ struct DeviceEntry {
std::vector<PropertyWidget> propertyWidgets;
};
class PowerApp : public App {
Timer update_timer = Timer(Timer::Type::Periodic, millis_to_ticks(1000),[]() { onTimer(); });
struct Context {
uint32_t appInstanceId;
std::unique_ptr<Timer> timer;
std::vector<DeviceEntry> entries;
static void onTimer() {
auto app = optApp();
if (app != nullptr) {
app->updateUi();
}
}
static bool collectDevice(::Device* device, void* context) {
auto* devices = static_cast<std::vector<::Device*>*>(context);
devices->push_back(device);
return true;
}
void onPowerEnabledChanged(lv_event_t* event) {
lv_event_code_t code = lv_event_get_code(event);
auto* enable_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
if (code == LV_EVENT_VALUE_CHANGED) {
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
auto* device = static_cast<::Device*>(lv_event_get_user_data(event));
if (power_supply_is_allowed_to_charge(device) != is_on) {
power_supply_set_allowed_to_charge(device, is_on);
updateUi();
}
}
}
static void onPowerEnabledChangedCallback(lv_event_t* event) {
auto app = optApp();
if (app != nullptr) {
app->onPowerEnabledChanged(event);
}
}
void onQuickChargeChanged(lv_event_t* event) {
lv_event_code_t code = lv_event_get_code(event);
auto* qc_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
if (code == LV_EVENT_VALUE_CHANGED) {
bool is_on = lv_obj_has_state(qc_switch, LV_STATE_CHECKED);
auto* device = static_cast<::Device*>(lv_event_get_user_data(event));
if (power_supply_is_quick_charge_enabled(device) != is_on) {
power_supply_set_quick_charge_enabled(device, is_on);
updateUi();
}
}
}
static void onQuickChargeChangedCallback(lv_event_t* event) {
auto app = optApp();
if (app != nullptr) {
app->onQuickChargeChanged(event);
}
}
static void setPropertyLabelText(lv_obj_t* label, PowerSupplyProperty property, const PowerSupplyPropertyValue& value) {
switch (property) {
case POWER_SUPPLY_PROP_IS_CHARGING:
lv_label_set_text_fmt(label, "Charging: %s", value.int_value ? "yes" : "no");
break;
case POWER_SUPPLY_PROP_VOLTAGE:
lv_label_set_text_fmt(label, "Battery voltage: %d mV", value.int_value);
break;
case POWER_SUPPLY_PROP_CAPACITY:
lv_label_set_text_fmt(label, "Charge level: %d%%", value.int_value);
break;
case POWER_SUPPLY_PROP_CURRENT:
lv_label_set_text_fmt(label, "Current: %d mA", value.int_value);
break;
}
}
void updateUi() {
if (entries.empty()) {
return;
}
lvgl_lock();
for (auto& entry : entries) {
if (entry.enableSwitch != nullptr) {
lv_obj_set_state(entry.enableSwitch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(entry.device));
}
if (entry.quickChargeSwitch != nullptr) {
lv_obj_set_state(entry.quickChargeSwitch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(entry.device));
}
PowerSupplyPropertyValue value;
for (auto& widget : entry.propertyWidgets) {
if (power_supply_get_property(entry.device, widget.property, &value) == ERROR_NONE) {
setPropertyLabelText(widget.label, widget.property, value);
}
}
}
lvgl_unlock();
}
public:
void onCreate(AppContext& app) override {}
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
std::vector<::Device*> devices;
device_for_each_of_type(&POWER_SUPPLY_TYPE, &devices, collectDevice);
if (devices.empty()) {
return;
}
lv_obj_t* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_style_border_width(wrapper, 0, 0);
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
entries.clear();
entries.reserve(devices.size());
for (size_t i = 0; i < devices.size(); i++) {
::Device* device = devices[i];
DeviceEntry entry;
entry.device = device;
lv_obj_t* header = lv_label_create(wrapper);
lv_label_set_text_fmt(header, "%s:", device->name);
if (power_supply_supports_charge_control(device)) {
lv_obj_t* switch_container = lv_obj_create(wrapper);
lv_obj_set_width(switch_container, LV_PCT(100));
lv_obj_set_height(switch_container, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(switch_container, 0, 0);
lv_obj_set_style_pad_gap(switch_container, 0, 0);
lvgl::obj_set_style_bg_invisible(switch_container);
lv_obj_t* label = lv_label_create(switch_container);
lv_label_set_text(label, "Charging enabled");
lv_obj_set_align(label, LV_ALIGN_LEFT_MID);
lv_obj_t* enable_switch = lv_switch_create(switch_container);
lv_obj_add_event_cb(enable_switch, onPowerEnabledChangedCallback, LV_EVENT_VALUE_CHANGED, device);
lv_obj_set_align(enable_switch, LV_ALIGN_RIGHT_MID);
lv_obj_set_state(enable_switch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(device));
entry.enableSwitch = enable_switch;
}
if (power_supply_supports_quick_charge(device)) {
lv_obj_t* qc_container = lv_obj_create(wrapper);
lv_obj_set_width(qc_container, LV_PCT(100));
lv_obj_set_height(qc_container, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(qc_container, 0, 0);
lv_obj_set_style_pad_gap(qc_container, 0, 0);
lvgl::obj_set_style_bg_invisible(qc_container);
lv_obj_t* label = lv_label_create(qc_container);
lv_label_set_text(label, "Quick charge");
lv_obj_set_align(label, LV_ALIGN_LEFT_MID);
lv_obj_t* qc_switch = lv_switch_create(qc_container);
lv_obj_add_event_cb(qc_switch, onQuickChargeChangedCallback, LV_EVENT_VALUE_CHANGED, device);
lv_obj_set_align(qc_switch, LV_ALIGN_RIGHT_MID);
lv_obj_set_state(qc_switch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(device));
entry.quickChargeSwitch = qc_switch;
}
PowerSupplyPropertyValue value;
for (auto property : DISPLAYED_PROPERTIES) {
if (power_supply_get_property(device, property, &value) == ERROR_NONE) {
lv_obj_t* label = lv_label_create(wrapper);
lv_obj_set_style_margin_left(label, 24, LV_STATE_DEFAULT);
setPropertyLabelText(label, property, value);
entry.propertyWidgets.push_back({ property, label });
}
}
entries.push_back(entry);
}
update_timer.start();
}
void onHide(AppContext& app) override {
update_timer.stop();
entries.clear();
}
};
extern const AppManifest manifest = {
.appId = "Power",
.appName = "Power",
.appIcon = LVGL_ICON_SHARED_ELECTRIC_BOLT,
.appCategory = Category::Settings,
.createApp = create<PowerApp>
bool collectDevice(::Device* device, void* context) {
auto* devices = static_cast<std::vector<::Device*>*>(context);
devices->push_back(device);
return true;
}
void setPropertyLabelText(lv_obj_t* label, PowerSupplyProperty property, const PowerSupplyPropertyValue& value) {
switch (property) {
case POWER_SUPPLY_PROP_IS_CHARGING:
lv_label_set_text_fmt(label, "Charging: %s", value.int_value ? "yes" : "no");
break;
case POWER_SUPPLY_PROP_VOLTAGE:
lv_label_set_text_fmt(label, "Battery voltage: %d mV", value.int_value);
break;
case POWER_SUPPLY_PROP_CAPACITY:
lv_label_set_text_fmt(label, "Charge level: %d%%", value.int_value);
break;
case POWER_SUPPLY_PROP_CURRENT:
lv_label_set_text_fmt(label, "Current: %d mA", value.int_value);
break;
}
}
void updateUi(Context* ctx) {
if (ctx->entries.empty()) {
return;
}
lvgl_lock();
for (auto& entry : ctx->entries) {
if (entry.enableSwitch != nullptr) {
lv_obj_set_state(entry.enableSwitch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(entry.device));
}
if (entry.quickChargeSwitch != nullptr) {
lv_obj_set_state(entry.quickChargeSwitch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(entry.device));
}
PowerSupplyPropertyValue value;
for (auto& widget : entry.propertyWidgets) {
if (power_supply_get_property(entry.device, widget.property, &value) == ERROR_NONE) {
setPropertyLabelText(widget.label, widget.property, value);
}
}
}
lvgl_unlock();
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onPowerEnabledChanged(lv_event_t* event) {
lv_event_code_t code = lv_event_get_code(event);
auto* enable_switch = lv_event_get_target_obj(event);
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* device = static_cast<::Device*>(lv_obj_get_user_data(enable_switch));
if (code == LV_EVENT_VALUE_CHANGED) {
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
if (power_supply_is_allowed_to_charge(device) != is_on) {
power_supply_set_allowed_to_charge(device, is_on);
updateUi(ctx);
}
}
}
void onQuickChargeChanged(lv_event_t* event) {
lv_event_code_t code = lv_event_get_code(event);
auto* qc_switch = lv_event_get_target_obj(event);
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* device = static_cast<::Device*>(lv_obj_get_user_data(qc_switch));
if (code == LV_EVENT_VALUE_CHANGED) {
bool is_on = lv_obj_has_state(qc_switch, LV_STATE_CHECKED);
if (power_supply_is_quick_charge_enabled(device) != is_on) {
power_supply_set_quick_charge_enabled(device, is_on);
updateUi(ctx);
}
}
}
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, "Power");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
std::vector<::Device*> devices;
device_for_each_of_type(&POWER_SUPPLY_TYPE, &devices, collectDevice);
if (devices.empty()) {
return;
}
lv_obj_t* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_style_border_width(wrapper, 0, 0);
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
ctx->entries.clear();
ctx->entries.reserve(devices.size());
for (size_t i = 0; i < devices.size(); i++) {
::Device* device = devices[i];
DeviceEntry entry;
entry.device = device;
lv_obj_t* header = lv_label_create(wrapper);
lv_label_set_text_fmt(header, "%s:", device->name);
if (power_supply_supports_charge_control(device)) {
lv_obj_t* switch_container = lv_obj_create(wrapper);
lv_obj_set_width(switch_container, LV_PCT(100));
lv_obj_set_height(switch_container, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(switch_container, 0, 0);
lv_obj_set_style_pad_gap(switch_container, 0, 0);
lvgl::obj_set_style_bg_invisible(switch_container);
lv_obj_t* label = lv_label_create(switch_container);
lv_label_set_text(label, "Charging enabled");
lv_obj_set_align(label, LV_ALIGN_LEFT_MID);
lv_obj_t* enable_switch = lv_switch_create(switch_container);
lv_obj_set_user_data(enable_switch, device);
lv_obj_add_event_cb(enable_switch, onPowerEnabledChanged, LV_EVENT_VALUE_CHANGED, ctx);
lv_obj_set_align(enable_switch, LV_ALIGN_RIGHT_MID);
lv_obj_set_state(enable_switch, LV_STATE_CHECKED, power_supply_is_allowed_to_charge(device));
entry.enableSwitch = enable_switch;
}
if (power_supply_supports_quick_charge(device)) {
lv_obj_t* qc_container = lv_obj_create(wrapper);
lv_obj_set_width(qc_container, LV_PCT(100));
lv_obj_set_height(qc_container, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(qc_container, 0, 0);
lv_obj_set_style_pad_gap(qc_container, 0, 0);
lvgl::obj_set_style_bg_invisible(qc_container);
lv_obj_t* label = lv_label_create(qc_container);
lv_label_set_text(label, "Quick charge");
lv_obj_set_align(label, LV_ALIGN_LEFT_MID);
lv_obj_t* qc_switch = lv_switch_create(qc_container);
lv_obj_set_user_data(qc_switch, device);
lv_obj_add_event_cb(qc_switch, onQuickChargeChanged, LV_EVENT_VALUE_CHANGED, ctx);
lv_obj_set_align(qc_switch, LV_ALIGN_RIGHT_MID);
lv_obj_set_state(qc_switch, LV_STATE_CHECKED, power_supply_is_quick_charge_enabled(device));
entry.quickChargeSwitch = qc_switch;
}
PowerSupplyPropertyValue value;
for (auto property : DISPLAYED_PROPERTIES) {
if (power_supply_get_property(device, property, &value) == ERROR_NONE) {
lv_obj_t* label = lv_label_create(wrapper);
lv_obj_set_style_margin_left(label, 24, LV_STATE_DEFAULT);
setPropertyLabelText(label, property, value);
entry.propertyWidgets.push_back({ property, label });
}
}
ctx->entries.push_back(entry);
}
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
// Runs for this app instance's whole lifetime, mirroring GpsSettings/SystemInfo - there's no
// push notification for power-supply property changes, so this is the only way this screen
// finds out about them.
ctx.timer = std::make_unique<Timer>(Timer::Type::Periodic, millis_to_ticks(1000), [&ctx] {
updateUi(&ctx);
});
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
ctx.timer->start();
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
ctx.timer->stop();
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "Power",
.name = "Power",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace
+138 -95
View File
@@ -1,12 +1,12 @@
#include "Tactility/Tactility.h"
#include "tactility/drivers/display.h"
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppRegistration.h>
#include <Tactility/service/loader/Loader.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/icons/shared.h>
#include <lvgl.h>
#include <lvgl/fonts.h>
#include <tactility/device.h>
@@ -14,121 +14,164 @@
namespace tt::app::poweroff {
extern const AppManifest manifest;
extern const ::AppManifest manifest;
class PowerOffApp final : public App {
namespace {
static void showPoweredOffScreen() {
auto* screen = lv_obj_create(nullptr);
lv_obj_set_style_bg_color(screen, lv_color_white(), 0);
lv_obj_set_flex_flow(screen, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(screen, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
struct Context {
uint32_t appInstanceId;
};
auto* title = lv_label_create(screen);
lv_label_set_text(title, "Tactility");
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
lv_obj_set_style_text_color(title, lv_color_black(), 0);
auto* subtitle = lv_label_create(screen);
lv_label_set_text(subtitle, "Powered off");
lv_obj_set_style_text_color(subtitle, lv_color_black(), 0);
void showPoweredOffScreen() {
auto* screen = lv_obj_create(nullptr);
lv_obj_set_style_bg_color(screen, lv_color_white(), 0);
lv_obj_set_flex_flow(screen, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(screen, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_screen_load(screen);
auto* title = lv_label_create(screen);
lv_label_set_text(title, "Tactility");
lv_obj_set_style_text_font(title, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
lv_obj_set_style_text_color(title, lv_color_black(), 0);
auto* subtitle = lv_label_create(screen);
lv_label_set_text(subtitle, "Powered off");
lv_obj_set_style_text_color(subtitle, lv_color_black(), 0);
lv_screen_load(screen);
}
bool anyDeviceSupportsPowerOff() {
bool any_supported = false;
device_for_each_of_type(&POWER_SUPPLY_TYPE, &any_supported, [](Device* device, void* context) {
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
*static_cast<bool*>(context) = true;
return false;
}
return true;
});
return any_supported;
}
void onYesPressed(lv_event_t* /*event*/) {
if (!anyDeviceSupportsPowerOff()) {
return;
}
static bool anyDeviceSupportsPowerOff() {
bool any_supported = false;
device_for_each_of_type(&POWER_SUPPLY_TYPE, &any_supported, [](Device* device, void* context) {
Device* display;
error_t error = device_get_first_by_type(&DISPLAY_TYPE, &display);
// TODO: remove this logic path when all displays have been migrated to kernel display drivers
if (error != ERROR_NONE) {
// No display, power off now
device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) {
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
*static_cast<bool*>(context) = true;
return false;
power_supply_power_off(device);
}
return true;
});
return any_supported;
return;
}
static void onYesPressed(lv_event_t* /*event*/) {
if (!anyDeviceSupportsPowerOff()) {
return;
bool is_slow_refresh = display_has_capability(display, DISPLAY_CAPABILITY_SLOW_REFRESH);
if (is_slow_refresh) {
auto* lvgl_display = lv_display_get_default();
showPoweredOffScreen();
if (lvgl_display != nullptr) {
lv_refr_now(lvgl_display);
}
}
Device* display;
error_t error = device_get_first_by_type(&DISPLAY_TYPE, &display);
// TODO: remove this logic path when all displays have been migrated to kernel display drivers
if (error != ERROR_NONE) {
// No display, power off now
device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) {
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
power_supply_power_off(device);
}
return true;
});
return;
}
bool is_slow_refresh = display_has_capability(display, DISPLAY_CAPABILITY_SLOW_REFRESH);
getMainDispatcher().dispatch([is_slow_refresh] {
// Not necessary for LilyGO Paper S3, but other drivers with async rendering might need us to wait a bit.
if (is_slow_refresh) {
auto* lvgl_display = lv_display_get_default();
showPoweredOffScreen();
if (lvgl_display != nullptr) {
lv_refr_now(lvgl_display);
}
vTaskDelay(pdMS_TO_TICKS(2000));
}
getMainDispatcher().dispatch([is_slow_refresh] {
// Not necessary for LilyGO Paper S3, but other drivers with async rendering might need us to wait a bit.
if (is_slow_refresh) {
vTaskDelay(pdMS_TO_TICKS(2000));
device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) {
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
power_supply_power_off(device);
}
device_for_each_of_type(&POWER_SUPPLY_TYPE, nullptr, [](Device* device, void* /*context*/) {
if (device_is_ready(device) && power_supply_supports_power_off(device)) {
power_supply_power_off(device);
}
return true;
});
return true;
});
});
}
void onNoPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
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_flex_align(parent, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
auto* label = lv_label_create(parent);
lv_label_set_text(label, "Power off?");
lv_obj_set_style_text_font(label, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
auto* button_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_size(button_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_border_width(button_wrapper, 0, 0);
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
auto* yes_button = lv_button_create(button_wrapper);
auto* yes_label = lv_label_create(yes_button);
lv_label_set_text(yes_label, "Yes");
lv_obj_add_event_cb(yes_button, onYesPressed, LV_EVENT_SHORT_CLICKED, nullptr);
auto* no_button = lv_button_create(button_wrapper);
auto* no_label = lv_label_create(no_button);
lv_label_set_text(no_label, "No");
lv_obj_add_event_cb(no_button, onNoPressed, LV_EVENT_SHORT_CLICKED, ctx);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
static void onNoPressed(lv_event_t* /*event*/) {
stop(manifest.appId);
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
public:
return 0;
}
void onShow(AppContext&, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(parent, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
} // namespace
auto* label = lv_label_create(parent);
lv_label_set_text(label, "Power off?");
lv_obj_set_style_text_font(label, lvgl_get_text_font(FONT_SIZE_LARGE), 0);
auto* button_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(button_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_size(button_wrapper, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_border_width(button_wrapper, 0, 0);
lv_obj_set_flex_align(button_wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
auto* yes_button = lv_button_create(button_wrapper);
auto* yes_label = lv_label_create(yes_button);
lv_label_set_text(yes_label, "Yes");
lv_obj_add_event_cb(yes_button, onYesPressed, LV_EVENT_SHORT_CLICKED, nullptr);
auto* no_button = lv_button_create(button_wrapper);
auto* no_label = lv_label_create(no_button);
lv_label_set_text(no_label, "No");
lv_obj_add_event_cb(no_button, onNoPressed, LV_EVENT_SHORT_CLICKED, nullptr);
}
};
extern const AppManifest manifest = {
.appId = "PowerOff",
.appName = "Power Off",
.appIcon = LVGL_ICON_SHARED_POWER_SETTINGS_NEW,
.appCategory = Category::System,
.appFlags = AppManifest::Flags::HideStatusBar | AppManifest::Flags::Hidden,
.createApp = create<PowerOffApp>
extern const ::AppManifest manifest = {
.id = "PowerOff",
.name = "Power Off",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
+138 -132
View File
@@ -4,100 +4,77 @@
#if TT_FEATURE_SCREENSHOT_ENABLED
#include <Tactility/Platform.h>
#include <Tactility/app/App.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/lvgl/Lvgl.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/screenshot/Screenshot.h>
#include <Tactility/Paths.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/Timer.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
#include <lvgl.h>
#include <lvgl/lvgl.h>
#include <lvgl/icons/shared.h>
#include <lvgl/widgets/toolbar.h>
namespace tt::app::screenshot {
constexpr auto* TAG = "Screenshot";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
class ScreenshotApp final : public App {
namespace {
struct Context {
uint32_t appInstanceId;
lv_obj_t* modeDropdown = nullptr;
lv_obj_t* pathTextArea = nullptr;
lv_obj_t* startStopButtonLabel = nullptr;
lv_obj_t* timerWrapper = nullptr;
lv_obj_t* delayTextArea = nullptr;
std::unique_ptr<Timer> updateTimer;
void createTimerSettingsWidgets(lv_obj_t* parent);
void createModeSettingWidgets(lv_obj_t* parent);
void createFilePathWidgets(lv_obj_t* parent);
void updateScreenshotMode();
public:
ScreenshotApp();
~ScreenshotApp() override;
void onShow(AppContext& app, lv_obj_t* parent) override;
void onStartPressed();
void onModeSet();
void onTimerTick();
};
/** Returns the app data if the app is active. Note that this could clash if the same app is started twice and a background thread is slow. */
std::shared_ptr<ScreenshotApp> optApp() {
auto appContext = getCurrentAppContext();
if (appContext != nullptr && appContext->getManifest().appId == manifest.appId) {
return std::static_pointer_cast<ScreenshotApp>(appContext->getApp());
void updateScreenshotMode(Context* ctx) {
auto service = service::screenshot::optScreenshotService();
if (service == nullptr) {
LOG_E(TAG, "Service not found/running");
return;
}
lv_obj_t* label = ctx->startStopButtonLabel;
if (service->isTaskStarted()) {
lv_label_set_text(label, "Stop");
} else {
return nullptr;
lv_label_set_text(label, "Start");
}
uint32_t selected = lv_dropdown_get_selected(ctx->modeDropdown);
if (selected == 0) { // Timer
lv_obj_remove_flag(ctx->timerWrapper, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(ctx->timerWrapper, LV_OBJ_FLAG_HIDDEN);
}
}
static void onStartPressedCallback(lv_event_t* event) {
auto app = optApp();
if (app != nullptr) {
app->onStartPressed();
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
static void onModeSetCallback(lv_event_t* event) {
auto app = optApp();
if (app != nullptr) {
app->onModeSet();
}
}
void onStartPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ScreenshotApp::ScreenshotApp() {
updateTimer = std::make_unique<Timer>(Timer::Type::Periodic, 500 / portTICK_PERIOD_MS, [this] {
onTimerTick();
});
}
ScreenshotApp::~ScreenshotApp() {
if (updateTimer->isRunning()) {
updateTimer->stop();
}
}
void ScreenshotApp::onTimerTick() {
if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) {
updateScreenshotMode();
lvgl_unlock();
}
}
void ScreenshotApp::onModeSet() {
updateScreenshotMode();
}
void ScreenshotApp::onStartPressed() {
auto service = service::screenshot::optScreenshotService();
if (service == nullptr) {
LOG_E(TAG, "Service not found/running");
@@ -108,11 +85,11 @@ void ScreenshotApp::onStartPressed() {
LOG_I(TAG, "Stop screenshot");
service->stop();
} else {
uint32_t selected = lv_dropdown_get_selected(modeDropdown);
const char* path = lv_textarea_get_text(pathTextArea);
uint32_t selected = lv_dropdown_get_selected(ctx->modeDropdown);
const char* path = lv_textarea_get_text(ctx->pathTextArea);
if (selected == 0) {
LOG_I(TAG, "Start timed screenshots");
const char* delay_text = lv_textarea_get_text(delayTextArea);
const char* delay_text = lv_textarea_get_text(ctx->delayTextArea);
int delay = atoi(delay_text);
if (delay > 0) {
service->startTimed(path, delay, 1);
@@ -125,33 +102,15 @@ void ScreenshotApp::onStartPressed() {
}
}
updateScreenshotMode();
updateScreenshotMode(ctx);
}
void ScreenshotApp::updateScreenshotMode() {
auto service = service::screenshot::optScreenshotService();
if (service == nullptr) {
LOG_E(TAG, "Service not found/running");
return;
}
lv_obj_t* label = startStopButtonLabel;
if (service->isTaskStarted()) {
lv_label_set_text(label, "Stop");
} else {
lv_label_set_text(label, "Start");
}
uint32_t selected = lv_dropdown_get_selected(modeDropdown);
if (selected == 0) { // Timer
lv_obj_remove_flag(timerWrapper, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(timerWrapper, LV_OBJ_FLAG_HIDDEN);
}
void onModeSet(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
updateScreenshotMode(ctx);
}
void ScreenshotApp::createModeSettingWidgets(lv_obj_t* parent) {
void createModeSettingWidgets(Context* ctx, lv_obj_t* parent) {
auto service = service::screenshot::optScreenshotService();
if (service == nullptr) {
LOG_E(TAG, "Service not found/running");
@@ -167,23 +126,23 @@ void ScreenshotApp::createModeSettingWidgets(lv_obj_t* parent) {
lv_label_set_text(mode_label, "Mode:");
lv_obj_align(mode_label, LV_ALIGN_LEFT_MID, 0, 0);
modeDropdown = lv_dropdown_create(mode_wrapper);
lv_dropdown_set_options(modeDropdown, "Timer\nApp start");
lv_obj_align_to(modeDropdown, mode_label, LV_ALIGN_OUT_RIGHT_MID, 8, 0);
lv_obj_add_event_cb(modeDropdown, onModeSetCallback, LV_EVENT_VALUE_CHANGED, nullptr);
ctx->modeDropdown = lv_dropdown_create(mode_wrapper);
lv_dropdown_set_options(ctx->modeDropdown, "Timer\nApp start");
lv_obj_align_to(ctx->modeDropdown, mode_label, LV_ALIGN_OUT_RIGHT_MID, 8, 0);
lv_obj_add_event_cb(ctx->modeDropdown, onModeSet, LV_EVENT_VALUE_CHANGED, ctx);
service::screenshot::Mode mode = service->getMode();
if (mode == service::screenshot::Mode::Apps) {
lv_dropdown_set_selected(modeDropdown, 1);
lv_dropdown_set_selected(ctx->modeDropdown, 1);
}
auto* button = lv_button_create(mode_wrapper);
lv_obj_align(button, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(button, &onStartPressedCallback, LV_EVENT_SHORT_CLICKED, nullptr);
startStopButtonLabel = lv_label_create(button);
lv_obj_align(startStopButtonLabel, LV_ALIGN_CENTER, 0, 0);
lv_obj_add_event_cb(button, onStartPressed, LV_EVENT_SHORT_CLICKED, ctx);
ctx->startStopButtonLabel = lv_label_create(button);
lv_obj_align(ctx->startStopButtonLabel, LV_ALIGN_CENTER, 0, 0);
}
void ScreenshotApp::createFilePathWidgets(lv_obj_t* parent) {
void createFilePathWidgets(Context* ctx, lv_obj_t* parent) {
auto* path_wrapper = lv_obj_create(parent);
lv_obj_set_size(path_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(path_wrapper, 0, 0);
@@ -198,29 +157,29 @@ void ScreenshotApp::createFilePathWidgets(lv_obj_t* parent) {
lv_label_set_text(path_label, "Path:");
lv_obj_align(path_label, LV_ALIGN_LEFT_MID, 0, 0);
pathTextArea = lv_textarea_create(path_wrapper);
lv_textarea_set_one_line(pathTextArea, true);
lv_obj_set_flex_grow(pathTextArea, 1);
ctx->pathTextArea = lv_textarea_create(path_wrapper);
lv_textarea_set_one_line(ctx->pathTextArea, true);
lv_obj_set_flex_grow(ctx->pathTextArea, 1);
if (kernel::getPlatform() == kernel::PlatformEsp) {
std::string sdcard_path;
if (findFirstMountedSdCardPath(sdcard_path)) {
std::string lvgl_mount_path = lvgl::PATH_PREFIX + sdcard_path + "/screenshots";
lv_textarea_set_text(pathTextArea, lvgl_mount_path.c_str());
lv_textarea_set_text(ctx->pathTextArea, lvgl_mount_path.c_str());
} else {
lv_textarea_set_text(pathTextArea, "Error: no SD card");
lv_textarea_set_text(ctx->pathTextArea, "Error: no SD card");
}
} else { // PC
lv_textarea_set_text(pathTextArea, lvgl::PATH_PREFIX);
lv_textarea_set_text(ctx->pathTextArea, lvgl::PATH_PREFIX);
}
}
void ScreenshotApp::createTimerSettingsWidgets(lv_obj_t* parent) {
timerWrapper = lv_obj_create(parent);
lv_obj_set_size(timerWrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timerWrapper, 0, 0);
lv_obj_set_style_border_width(timerWrapper, 0, 0);
void createTimerSettingsWidgets(Context* ctx, lv_obj_t* parent) {
ctx->timerWrapper = lv_obj_create(parent);
lv_obj_set_size(ctx->timerWrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ctx->timerWrapper, 0, 0);
lv_obj_set_style_border_width(ctx->timerWrapper, 0, 0);
auto* delay_wrapper = lv_obj_create(timerWrapper);
auto* delay_wrapper = lv_obj_create(ctx->timerWrapper);
lv_obj_set_size(delay_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(delay_wrapper, 0, 0);
lv_obj_set_style_border_width(delay_wrapper, 0, 0);
@@ -234,11 +193,11 @@ void ScreenshotApp::createTimerSettingsWidgets(lv_obj_t* parent) {
lv_label_set_text(delay_label, "Delay:");
lv_obj_align(delay_label, LV_ALIGN_LEFT_MID, 0, 0);
delayTextArea = lv_textarea_create(delay_wrapper);
lv_textarea_set_one_line(delayTextArea, true);
lv_textarea_set_accepted_chars(delayTextArea, "0123456789");
lv_textarea_set_text(delayTextArea, "10");
lv_obj_set_flex_grow(delayTextArea, 1);
ctx->delayTextArea = lv_textarea_create(delay_wrapper);
lv_textarea_set_one_line(ctx->delayTextArea, true);
lv_textarea_set_accepted_chars(ctx->delayTextArea, "0123456789");
lv_textarea_set_text(ctx->delayTextArea, "10");
lv_obj_set_flex_grow(ctx->delayTextArea, 1);
auto* delay_unit_label_wrapper = lv_obj_create(delay_wrapper);
lv_obj_set_style_border_width(delay_unit_label_wrapper, 0, 0);
@@ -249,15 +208,19 @@ void ScreenshotApp::createTimerSettingsWidgets(lv_obj_t* parent) {
lv_label_set_text(delay_unit_label, "seconds");
}
void ScreenshotApp::onShow(AppContext& appContext, lv_obj_t* parent) {
if (updateTimer->isRunning()) {
updateTimer->stop();
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
if (ctx->updateTimer->isRunning()) {
ctx->updateTimer->stop();
}
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, appContext);
auto* toolbar = lvgl_toolbar_create(parent, "Screenshot");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
auto* wrapper = lv_obj_create(parent);
@@ -266,23 +229,66 @@ void ScreenshotApp::onShow(AppContext& appContext, lv_obj_t* parent) {
lv_obj_set_style_border_width(wrapper, 0, 0);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
createModeSettingWidgets(wrapper);
createFilePathWidgets(wrapper);
createTimerSettingsWidgets(wrapper);
createModeSettingWidgets(ctx, wrapper);
createFilePathWidgets(ctx, wrapper);
createTimerSettingsWidgets(ctx, wrapper);
updateScreenshotMode();
updateScreenshotMode(ctx);
if (!updateTimer->isRunning()) {
updateTimer->start();
if (!ctx->updateTimer->isRunning()) {
ctx->updateTimer->start();
}
}
extern const AppManifest manifest = {
.appId = "Screenshot",
.appName = "Screenshot",
.appIcon = LVGL_ICON_SHARED_IMAGE,
.appCategory = Category::System,
.createApp = create<ScreenshotApp>
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.updateTimer = std::make_unique<Timer>(Timer::Type::Periodic, 500 / portTICK_PERIOD_MS, [&ctx] {
if (lvgl_try_lock(500 / portTICK_PERIOD_MS)) {
updateScreenshotMode(&ctx);
lvgl_unlock();
}
});
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
if (ctx.updateTimer->isRunning()) {
ctx.updateTimer->stop();
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "Screenshot",
.name = "Screenshot",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace
@@ -1,119 +1,160 @@
#include <Tactility/app/selectiondialog/SelectionDialog.h>
#include <lvgl/widgets/toolbar.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/StringUtils.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
#include <lvgl.h>
#include <lvgl/widgets/toolbar.h>
namespace tt::app::selectiondialog {
constexpr auto* PARAMETER_BUNDLE_KEY_TITLE = "title";
constexpr auto* PARAMETER_BUNDLE_KEY_ITEMS = "items";
constexpr auto* RESULT_BUNDLE_KEY_INDEX = "index";
constexpr auto* PARAMETER_ITEM_CONCATENATION_TOKEN = ";;";
constexpr auto* TAG = "SelectionDialog";
constexpr auto* DEFAULT_TITLE = "Select...";
constexpr auto* TAG = "SelectionDialog";
extern const ::AppManifest manifest;
extern const AppManifest manifest;
namespace {
LaunchId start(const std::string& title, const std::vector<std::string>& items) {
std::string items_joined = string::join(items, PARAMETER_ITEM_CONCATENATION_TOKEN);
auto bundle = std::make_shared<Bundle>();
bundle->putString(PARAMETER_BUNDLE_KEY_TITLE, title);
bundle->putString(PARAMETER_BUNDLE_KEY_ITEMS, items_joined);
return app::start(manifest.appId, bundle);
struct Context {
uint32_t appInstanceId;
// Set once in appMain() from its own argc/argv parameters, read by createWidgets() - see
// AlertDialog.cpp's Context::argc/argv for why this is safe without a lock.
int argc = 0;
char** argv = nullptr;
// The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this
// is a plain (non-atomic) field safely shared between the LVGL thread (writer, before
// emitting APP_EVENT_CLOSE) and this dialog's own thread (reader, after waking from it).
int32_t result = 1; // Cancelled - safety-net default if closed without selecting an item
};
struct ItemContext {
Context* ctx;
int32_t index;
};
void onItemDeleted(lv_event_t* e) {
delete static_cast<ItemContext*>(lv_event_get_user_data(e));
}
int32_t getResultIndex(const Bundle& bundle) {
int32_t index = -1;
bundle.optInt32(RESULT_BUNDLE_KEY_INDEX, index);
return index;
void onItemSelected(lv_event_t* e) {
auto* itemCtx = static_cast<ItemContext*>(lv_event_get_user_data(e));
LOG_I(TAG, "Selected item at index %d", (int)itemCtx->index);
itemCtx->ctx->result = itemCtx->index;
// Async, non-blocking - just wakes this dialog's own thread. Must NOT call
// app_manager_stop() here: that bound-waits (thread_join) for the dialog's thread to
// finish, which needs the LVGL lock (window_manager_remove()) - but this callback is
// running ON the LVGL task, which would deadlock against itself. The caller reaps this
// instance via app_manager_stop() after it receives the APP_EVENT_RESULT instead.
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(itemCtx->ctx->appInstanceId, &event);
}
static std::string getTitleParameter(std::shared_ptr<const Bundle> bundle) {
std::string result;
if (bundle->optString(PARAMETER_BUNDLE_KEY_TITLE, result)) {
return result;
void createChoiceItem(Context* ctx, lv_obj_t* list, const std::string& title, int32_t index) {
lv_obj_t* btn = lv_list_add_button(list, nullptr, title.c_str());
auto* itemCtx = new ItemContext { ctx, index };
lv_obj_add_event_cb(btn, onItemSelected, LV_EVENT_SHORT_CLICKED, itemCtx);
lv_obj_add_event_cb(btn, onItemDeleted, LV_EVENT_DELETE, itemCtx);
}
// Closes the dialog immediately with a fixed result, without ever showing a choice list -
// mirrors the original's 0-items (error) and 1-item (auto-select) shortcuts.
void closeWithResult(Context* ctx, int32_t result) {
ctx->result = result;
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &event);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
// argv layout: [0]=title, [1..argc)=items.
int argc = ctx->argc;
char** argv = ctx->argv;
int itemCount = argc - 1;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
const char* title = (argv[0][0] != '\0') ? argv[0] : DEFAULT_TITLE;
lvgl_toolbar_create(parent, title);
auto* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
if (itemCount <= 0 || argv[1][0] == '\0') {
LOG_E(TAG, "No items provided");
closeWithResult(ctx, -1);
} else if (itemCount == 1) {
LOG_W(TAG, "Auto-selecting single item");
closeWithResult(ctx, 0);
} else {
return DEFAULT_TITLE;
}
}
class SelectionDialogApp final : public App {
static void onListItemSelectedCallback(lv_event_t* e) {
auto app = std::static_pointer_cast<SelectionDialogApp>(getCurrentApp());
assert(app != nullptr);
app->onListItemSelected(e);
}
void onListItemSelected(lv_event_t* e) {
auto index = reinterpret_cast<std::size_t>(lv_event_get_user_data(e));
LOG_I(TAG, "Selected item at index %d", (int)index);
auto bundle = std::make_unique<Bundle>();
bundle->putInt32(RESULT_BUNDLE_KEY_INDEX, (int32_t)index);
setResult(Result::Ok, std::move(bundle));
stop(manifest.appId);
}
static void createChoiceItem(void* parent, const std::string& title, size_t index) {
auto* list = static_cast<lv_obj_t*>(parent);
lv_obj_t* btn = lv_list_add_button(list, nullptr, title.c_str());
lv_obj_add_event_cb(btn, onListItemSelectedCallback, LV_EVENT_SHORT_CLICKED, (void*)index);
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
std::string title = getTitleParameter(app.getParameters());
lvgl_toolbar_create(parent, title.c_str());
auto* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
auto parameters = app.getParameters();
check(parameters != nullptr, "Parameters missing");
std::string items_concatenated;
if (parameters->optString(PARAMETER_BUNDLE_KEY_ITEMS, items_concatenated)) {
std::vector<std::string> items = string::split(items_concatenated, PARAMETER_ITEM_CONCATENATION_TOKEN);
if (items.empty() || items.front().empty()) {
LOG_E(TAG, "No items provided");
setResult(Result::Error);
stop(manifest.appId);
} else if (items.size() == 1) {
auto result_bundle = std::make_unique<Bundle>();
result_bundle->putInt32(RESULT_BUNDLE_KEY_INDEX, 0);
setResult(Result::Ok, std::move(result_bundle));
stop(manifest.appId);
LOG_W(TAG, "Auto-selecting single item");
} else {
size_t index = 0;
for (const auto& item: items) {
createChoiceItem(list, item, index++);
}
}
} else {
LOG_E(TAG, "No items provided");
setResult(Result::Error);
stop(manifest.appId);
for (int32_t index = 0; index < itemCount; index++) {
createChoiceItem(ctx, list, argv[1 + index], index);
}
}
};
}
int32_t appMain(AppInstanceId appInstanceId, int argc, char* argv[]) {
Context ctx { appInstanceId };
ctx.argc = argc;
ctx.argv = argv;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
while (true) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
if (event.type == APP_EVENT_CLOSE) {
app_manager_finish(appInstanceId); // no-op: modal children never supersede anything
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return ctx.result;
}
} // namespace
namespace {
// Builds argv = [title, items...] for app_manager_start_for_result().
std::vector<const char*> buildArgv(const std::string& title, const std::vector<std::string>& items) {
std::vector<const char*> argv { title.c_str() };
for (const auto& item: items) {
argv.push_back(item.c_str());
}
return argv;
}
} // namespace
AppInstanceId start(AppInstanceId callerAppInstanceId, const std::string& title, const std::vector<std::string>& items) {
auto argv = buildArgv(title, items);
AppInstanceId instanceId = 0;
app_manager_start_for_result(manifest.id, callerAppInstanceId, static_cast<int>(argv.size()), argv.data(), &instanceId);
return instanceId;
}
extern const AppManifest manifest = {
.appId = "SelectionDialog",
.appName = "Selection Dialog",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<SelectionDialogApp>
.id = "SelectionDialog",
.name = "Selection Dialog",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
}
+87 -34
View File
@@ -1,61 +1,114 @@
#include <Tactility/app/AppRegistration.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/loader/Loader.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/icons/shared.h>
#include <lvgl/fonts.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/check.h>
#include <lvgl.h>
#include <algorithm>
#include <cstring>
#include <vector>
namespace tt::app::settings {
static void onAppPressed(lv_event_t* e) {
const auto* manifest = static_cast<const AppManifest*>(lv_event_get_user_data(e));
start(manifest->appId);
namespace {
uint32_t settingsInstanceId = 0;
void onAppPressed(lv_event_t* e) {
// Fire-and-forget top-level navigation, same as AppList's own app-launch buttons.
const auto* manifest = static_cast<const ::AppManifest*>(lv_event_get_user_data(e));
uint32_t instanceId = 0;
app_manager_start(manifest->id, &instanceId);
}
static void createWidget(const std::shared_ptr<AppManifest>& manifest, void* parent) {
check(parent);
auto* list = static_cast<lv_obj_t*>(parent);
const void* icon = !manifest->appIcon.empty() ? manifest->appIcon.c_str() : LVGL_ICON_SHARED_TOOLBAR;
auto* btn = lv_list_add_button(list, icon, manifest->appName.c_str());
void onBackPressed(lv_event_t*) {
// The global toolbar nav callback only knows how to stop old-model apps, so this
// new-model app overrides its own toolbar's nav action to close itself instead. Async,
// non-blocking - see AppList.cpp's onBackPressed() for why this must not call
// app_manager_stop() directly (would deadlock against the LVGL lock).
AppEvent event { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(settingsInstanceId, &event);
}
void createWidget(const ::AppManifest* manifest, lv_obj_t* list) {
check(list);
// The new AppManifest has no per-app icon - use a shared generic one for every entry,
// same fallback the old model used for apps that didn't provide one.
auto* btn = lv_list_add_button(list, LVGL_ICON_SHARED_TOOLBAR, manifest->name);
lv_obj_t* image = lv_obj_get_child(btn, 0);
lv_obj_set_style_text_font(image, lvgl_get_shared_icon_font(), LV_PART_MAIN);
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, (void*)manifest.get());
lv_obj_add_event_cb(btn, &onAppPressed, LV_EVENT_SHORT_CLICKED, const_cast<::AppManifest*>(manifest));
}
class SettingsApp final : public App {
void collectManifest(const ::AppManifest* manifest, void* context) {
auto* manifests = static_cast<std::vector<const ::AppManifest*>*>(context);
manifests->push_back(manifest);
}
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
void createWidgets(lv_obj_t* parent, void*) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
auto* toolbar = lvgl_toolbar_create(parent, "Settings");
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, nullptr);
auto* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
auto* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
auto manifests = getAppManifests();
std::ranges::sort(manifests, SortAppManifestByName);
for (const auto& manifest: manifests) {
if (manifest->appCategory == Category::Settings) {
createWidget(manifest, list);
}
std::vector<const ::AppManifest*> manifests;
app_manager_for_each_manifest(collectManifest, &manifests);
std::ranges::sort(manifests, [](const ::AppManifest* a, const ::AppManifest* b) {
return strcmp(a->name, b->name) < 0;
});
for (const auto* manifest: manifests) {
if (manifest->category == APP_CATEGORY_SETTINGS && (manifest->flags & APP_MANIFEST_FLAG_HIDDEN) == 0) {
createWidget(manifest, list);
}
}
};
}
extern const AppManifest manifest = {
.appId = "Settings",
.appName = "Settings",
.appIcon = LVGL_ICON_SHARED_SETTINGS,
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<SettingsApp>
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
settingsInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, nullptr);
while (true) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
if (event.type == APP_EVENT_CLOSE) {
app_manager_finish(appInstanceId);
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "Settings",
.name = "Settings",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
+217 -165
View File
@@ -1,15 +1,22 @@
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>
#include <Tactility/app/App.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/setup/Setup.h>
#include <Tactility/Preferences.h>
#include <Tactility/StringUtils.h>
#include <Tactility/app/timezone/TimeZone.h>
#include <Tactility/app/wifimanage/WifiManage.h>
#include <Tactility/file/File.h>
#include <Tactility/service/wifi/Wifi.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
#include <tactility/paths.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>
#include <lvgl.h>
#include <functional>
@@ -25,205 +32,250 @@
namespace tt::app::setup {
extern const AppManifest manifest;
extern const ::AppManifest manifest;
constexpr auto* PREFERENCES_NAMESPACE = "setup";
constexpr auto* PREFERENCES_KEY_COMPLETED = "completed";
constexpr auto* TAG = "setup";
namespace {
bool getCompletedMarkerPath(std::string& outPath) {
char root[128];
if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) {
return false;
}
outPath = std::string(root) + "/.setup_complete";
return true;
}
} // namespace
bool isCompleted() {
Preferences preferences(PREFERENCES_NAMESPACE);
bool completed = false;
preferences.optBool(PREFERENCES_KEY_COMPLETED, completed);
return completed;
std::string path;
if (!getCompletedMarkerPath(path)) {
LOG_E(TAG, "Setup path not found");
return false;
}
file::FileMutexGuard guard(path);
return file::isFile(path);
}
static void markCompleted() {
Preferences preferences(PREFERENCES_NAMESPACE);
preferences.putBool(PREFERENCES_KEY_COMPLETED, true);
namespace {
void markCompleted() {
std::string path;
if (!getCompletedMarkerPath(path)) {
return;
}
file::FileMutexGuard guard(path);
file::writeString(path, "");
}
enum class Phase {
Welcome,
StepIntro,
Done
};
struct StepConfiguration {
std::string title;
std::string description;
std::function<void()> run;
};
class SetupApp final : public App {
enum class Phase {
Welcome,
StepIntro,
Done
};
struct Context {
uint32_t appInstanceId;
Phase phase = Phase::Welcome;
size_t stepIndex = 0;
std::vector<StepConfiguration> steps;
bool isShown = false;
uint32_t pendingStepDialogId = 0;
lv_obj_t* titleLabel = nullptr;
lv_obj_t* descriptionLabel = nullptr;
lv_obj_t* skipButton = nullptr;
lv_obj_t* continueButton = nullptr;
};
static void onSkipClickedCallback(lv_event_t* e) {
auto* app = (SetupApp*)lv_event_get_user_data(e);
app->onSkipClicked();
void renderCurrent(Context* ctx) {
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_add_flag(ctx->skipButton, LV_OBJ_FLAG_HIDDEN);
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_remove_flag(ctx->skipButton, LV_OBJ_FLAG_HIDDEN);
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_add_flag(ctx->skipButton, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Finish");
break;
}
}
void advanceTo(Context* ctx, size_t index) {
if (index < ctx->steps.size()) {
ctx->stepIndex = index;
ctx->phase = Phase::StepIntro;
} else {
ctx->phase = Phase::Done;
}
static void onContinueClickedCallback(lv_event_t* e) {
auto* app = (SetupApp*)lv_event_get_user_data(e);
app->onContinueClicked();
}
lvgl_lock();
renderCurrent(ctx);
lvgl_unlock();
}
void renderCurrent() {
switch (phase) {
case Phase::Welcome: {
lv_label_set_text(titleLabel, "Welcome");
auto device_names = string::split(std::string(CONFIG_TT_DEVICE_NAME_SIMPLE), ",");
lv_label_set_text_fmt(descriptionLabel, "It's time to set up your %s!", device_names.front().c_str());
lv_obj_add_flag(skipButton, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(lv_obj_get_child(continueButton, 0), "Continue");
break;
}
case Phase::StepIntro: {
const auto& step = steps[stepIndex];
lv_label_set_text(titleLabel, step.title.c_str());
lv_label_set_text(descriptionLabel, step.description.c_str());
lv_obj_remove_flag(skipButton, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(lv_obj_get_child(skipButton, 0), "Skip");
lv_label_set_text(lv_obj_get_child(continueButton, 0), "Continue");
break;
}
case Phase::Done:
lv_label_set_text(titleLabel, "Setup Complete");
lv_label_set_text(descriptionLabel, "You're all set.");
lv_obj_add_flag(skipButton, LV_OBJ_FLAG_HIDDEN);
lv_label_set_text(lv_obj_get_child(continueButton, 0), "Finish");
break;
void onSkipClicked(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
if (ctx->phase == Phase::StepIntro) {
advanceTo(ctx, ctx->stepIndex + 1);
}
}
void onContinueClicked(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
switch (ctx->phase) {
case Phase::Welcome:
advanceTo(ctx, 0);
break;
case Phase::StepIntro:
ctx->steps[ctx->stepIndex].run();
break;
case Phase::Done: {
markCompleted();
// Async, non-blocking - must NOT call app_manager_stop()/app_manager_finish()
// directly here: this callback runs ON the LVGL task, and app-lifecycle
// transitions must happen on this app's own thread (woken via app_event_await()).
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
break;
}
}
}
void advanceTo(size_t index) {
if (index < steps.size()) {
stepIndex = index;
phase = Phase::StepIntro;
} else {
phase = Phase::Done;
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
// Widgets may not exist yet: onShow() runs asynchronously on the GUI task and
// may not have (re)created them by the time onResult() advances the state.
// onShow() calls renderCurrent() itself once the widgets are ready.
if (isShown) {
renderCurrent();
}
}
ctx->titleLabel = lv_label_create(parent);
lv_obj_set_width(ctx->titleLabel, LV_PCT(80));
lv_obj_set_style_text_align(ctx->titleLabel, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(ctx->titleLabel, LV_LABEL_LONG_WRAP);
auto* font = lvgl_get_text_font(FONT_SIZE_LARGE);
lv_obj_set_style_text_font(ctx->titleLabel, font, 0);
void onSkipClicked() {
if (phase == Phase::StepIntro) {
advanceTo(stepIndex + 1);
}
}
ctx->descriptionLabel = lv_label_create(parent);
lv_obj_set_width(ctx->descriptionLabel, LV_PCT(80));
lv_obj_set_style_text_align(ctx->descriptionLabel, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(ctx->descriptionLabel, LV_LABEL_LONG_WRAP);
lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0);
void onContinueClicked() {
switch (phase) {
case Phase::Welcome:
advanceTo(0);
break;
case Phase::StepIntro:
steps[stepIndex].run();
break;
case Phase::Done:
markCompleted();
stop(manifest.appId);
break;
}
}
int title_margin = lvgl_get_text_font_height(FONT_SIZE_LARGE);
lv_obj_align_to(ctx->titleLabel, ctx->descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -title_margin);
public:
ctx->skipButton = lv_button_create(parent);
lv_obj_t* skip_label = lv_label_create(ctx->skipButton);
lv_label_set_text(skip_label, "Skip");
lv_obj_center(skip_label);
lv_obj_align(ctx->skipButton, LV_ALIGN_BOTTOM_LEFT, 12, -12);
lv_obj_add_event_cb(ctx->skipButton, onSkipClicked, LV_EVENT_SHORT_CLICKED, ctx);
void onCreate(AppContext& app) override {
steps = {
ctx->continueButton = lv_button_create(parent);
lv_obj_t* continue_label = lv_label_create(ctx->continueButton);
lv_label_set_text(continue_label, "Continue");
lv_obj_center(continue_label);
lv_obj_align(ctx->continueButton, LV_ALIGN_BOTTOM_RIGHT, -12, -12);
lv_obj_add_event_cb(ctx->continueButton, onContinueClicked, LV_EVENT_SHORT_CLICKED, ctx);
renderCurrent(ctx);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.steps = {
#if defined(CONFIG_TT_TOUCH_CALIBRATION_REQUIRED)
{
.title = "Touch Calibration",
.description = "Let's calibrate the touch screen.",
.run = [] { touchcalibration::start(); }
},
{
.title = "Touch Calibration",
.description = "Let's calibrate the touch screen.",
.run = [&ctx] { ctx.pendingStepDialogId = touchcalibration::start(ctx.appInstanceId); }
},
#endif
{
.title = "Time Zone Setup",
.description = "Let's set the time zone.",
.run = [] { timezone::start(true); }
},
{
.title = "Wi-Fi Setup",
.description = "Let's connect to a Wi-Fi access point.",
.run = [] {
service::wifi::setEnabled(true);
wifimanage::start();
}
{
.title = "Time Zone Setup",
.description = "Let's set the time zone.",
.run = [&ctx] { ctx.pendingStepDialogId = timezone::start(ctx.appInstanceId, true); }
},
{
.title = "Wi-Fi Setup",
.description = "Let's connect to a Wi-Fi access point.",
.run = [&ctx] {
service::wifi::setEnabled(true);
ctx.pendingStepDialogId = wifimanage::start(ctx.appInstanceId);
}
};
}
};
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
case APP_EVENT_RESULT:
if (event.result.launch_id == ctx.pendingStepDialogId) {
ctx.pendingStepDialogId = 0;
advanceTo(&ctx, ctx.stepIndex + 1);
}
app_manager_stop(event.result.launch_id);
break;
default:
break;
}
}
void onShow(AppContext& app, lv_obj_t* parent) override {
titleLabel = lv_label_create(parent);
lv_obj_set_width(titleLabel, LV_PCT(80));
lv_obj_set_style_text_align(titleLabel, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(titleLabel, LV_LABEL_LONG_WRAP);
auto* font = lvgl_get_text_font(FONT_SIZE_LARGE);
lv_obj_set_style_text_font(titleLabel, font, 0);
window_manager_remove(window);
app_event_unsubscribe(&sub);
descriptionLabel = lv_label_create(parent);
lv_obj_set_width(descriptionLabel, LV_PCT(80));
lv_obj_set_style_text_align(descriptionLabel, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_long_mode(descriptionLabel, LV_LABEL_LONG_WRAP);
lv_obj_align(descriptionLabel, LV_ALIGN_CENTER, 0, 0);
int title_margin = lvgl_get_text_font_height(FONT_SIZE_LARGE);
lv_obj_align_to(titleLabel, descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -title_margin);
skipButton = lv_button_create(parent);
lv_obj_t* skip_label = lv_label_create(skipButton);
lv_label_set_text(skip_label, "Skip");
lv_obj_center(skip_label);
lv_obj_align(skipButton, LV_ALIGN_BOTTOM_LEFT, 12, -12);
lv_obj_add_event_cb(skipButton, onSkipClickedCallback, LV_EVENT_SHORT_CLICKED, this);
continueButton = lv_button_create(parent);
lv_obj_t* continue_label = lv_label_create(continueButton);
lv_label_set_text(continue_label, "Continue");
lv_obj_center(continue_label);
lv_obj_align(continueButton, LV_ALIGN_BOTTOM_RIGHT, -12, -12);
lv_obj_add_event_cb(continueButton, onContinueClickedCallback, LV_EVENT_SHORT_CLICKED, this);
isShown = true;
renderCurrent();
}
void onHide(AppContext& app) override {
isShown = false;
}
void onResult(AppContext& app, LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) override {
lvgl_lock();
advanceTo(stepIndex + 1);
lvgl_unlock();
}
};
extern const AppManifest manifest = {
.appId = "Setup",
.appName = "Setup",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden | AppManifest::Flags::HideStatusBar,
.createApp = create<SetupApp>
};
LaunchId start() {
return app::start(manifest.appId);
return 0;
}
} // namespace
void start() {
uint32_t instanceId = 0;
app_manager_start(manifest.id, &instanceId);
}
extern const ::AppManifest manifest = {
.id = "Setup",
.name = "Setup",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
}
+226 -185
View File
@@ -1,20 +1,24 @@
#include "tactility/time.h"
#include <Tactility/Paths.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/Tactility.h>
#include <Tactility/TactilityConfig.h>
#include <Tactility/Timer.h>
#include <Tactility/lvgl/Toolbar.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <algorithm>
#include <cstring>
#include <format>
#include <utility>
#include <lvgl/icons/shared.h>
#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#ifdef ESP_PLATFORM
#include <esp_vfs_fat.h>
@@ -26,7 +30,11 @@ namespace tt::app::systeminfo {
constexpr auto* TAG = "SystemInfo";
static size_t getHeapFree() {
extern const ::AppManifest manifest;
namespace {
size_t getHeapFree() {
#ifdef ESP_PLATFORM
return heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
#else
@@ -34,7 +42,7 @@ static size_t getHeapFree() {
#endif
}
static size_t getHeapTotal() {
size_t getHeapTotal() {
#ifdef ESP_PLATFORM
return heap_caps_get_total_size(MALLOC_CAP_INTERNAL);
#else
@@ -42,7 +50,7 @@ static size_t getHeapTotal() {
#endif
}
static size_t getSpiFree() {
size_t getSpiFree() {
#ifdef ESP_PLATFORM
return heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
#else
@@ -50,7 +58,7 @@ static size_t getSpiFree() {
#endif
}
static size_t getSpiTotal() {
size_t getSpiTotal() {
#ifdef ESP_PLATFORM
return heap_caps_get_total_size(MALLOC_CAP_SPIRAM);
#else
@@ -65,7 +73,7 @@ enum class StorageUnit {
Gigabytes
};
static StorageUnit getStorageUnit(uint64_t value) {
StorageUnit getStorageUnit(uint64_t value) {
using enum StorageUnit;
if (value / (1024 * 1024 * 1024) > 0) {
return Gigabytes;
@@ -78,7 +86,7 @@ static StorageUnit getStorageUnit(uint64_t value) {
}
}
static std::string getStorageUnitString(StorageUnit unit) {
std::string getStorageUnitString(StorageUnit unit) {
using enum StorageUnit;
switch (unit) {
case Bytes:
@@ -94,7 +102,7 @@ static std::string getStorageUnitString(StorageUnit unit) {
}
}
static std::string getStorageValue(StorageUnit unit, uint64_t bytes) {
std::string getStorageValue(StorageUnit unit, uint64_t bytes) {
using enum StorageUnit;
switch (unit) {
case Bytes:
@@ -115,7 +123,7 @@ struct MemoryBarWidgets {
lv_obj_t* label = nullptr;
};
static MemoryBarWidgets createMemoryBar(lv_obj_t* parent, const char* label) {
MemoryBarWidgets createMemoryBar(lv_obj_t* parent, const char* label) {
auto* container = lv_obj_create(parent);
lv_obj_set_size(container, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(container, 0, LV_STATE_DEFAULT);
@@ -144,7 +152,7 @@ static MemoryBarWidgets createMemoryBar(lv_obj_t* parent, const char* label) {
return {bar, bottom_label};
}
static void updateMemoryBar(const MemoryBarWidgets& widgets, uint64_t free, uint64_t total) {
void updateMemoryBar(const MemoryBarWidgets& widgets, uint64_t free, uint64_t total) {
uint64_t used = total - free;
// Scale down the uint64_t until it fits int32_t for the lv_bar
@@ -174,7 +182,7 @@ static void updateMemoryBar(const MemoryBarWidgets& widgets, uint64_t free, uint
#if configUSE_TRACE_FACILITY
static const char* getTaskState(const TaskStatus_t& task) {
const char* getTaskState(const TaskStatus_t& task) {
switch (task.eCurrentState) {
case eRunning:
return "running";
@@ -192,17 +200,17 @@ static const char* getTaskState(const TaskStatus_t& task) {
}
}
static void clearContainer(lv_obj_t* container) {
void clearContainer(lv_obj_t* container) {
lv_obj_clean(container);
}
static void addRtosTask(lv_obj_t* parent, const TaskStatus_t& task) {
void addRtosTask(lv_obj_t* parent, const TaskStatus_t& task) {
auto* label = lv_label_create(parent);
const char* name = (task.pcTaskName == nullptr || task.pcTaskName[0] == 0) ? "(unnamed)" : task.pcTaskName;
lv_label_set_text_fmt(label, "%s (%s)", name, getTaskState(task));
}
static void updateRtosTasks(lv_obj_t* parent) {
void updateRtosTasks(lv_obj_t* parent) {
clearContainer(parent);
UBaseType_t count = uxTaskGetNumberOfTasks();
@@ -224,7 +232,7 @@ static void updateRtosTasks(lv_obj_t* parent) {
#endif
static lv_obj_t* createTab(lv_obj_t* tabview, const char* name) {
lv_obj_t* createTab(lv_obj_t* tabview, const char* name) {
auto* tab = lv_tabview_add_tab(tabview, name);
lv_obj_set_flex_flow(tab, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(tab, 0, LV_STATE_DEFAULT);
@@ -232,36 +240,11 @@ static lv_obj_t* createTab(lv_obj_t* tabview, const char* name) {
return tab;
}
extern const AppManifest manifest;
struct Context {
uint32_t appInstanceId;
class SystemInfoApp;
static std::shared_ptr<SystemInfoApp> optApp() {
auto appContext = getCurrentAppContext();
if (appContext != nullptr && appContext->getManifest().appId == manifest.appId) {
return std::static_pointer_cast<SystemInfoApp>(appContext->getApp());
}
return nullptr;
}
class SystemInfoApp final : public App {
Timer memoryTimer = Timer(Timer::Type::Periodic, millis_to_ticks(10000), [] {
auto app = optApp();
if (app) {
lvgl_lock();
app->updateMemory();
lvgl_unlock();
}
});
Timer tasksTimer = Timer(Timer::Type::Periodic, millis_to_ticks(15000), [] {
auto app = optApp();
if (app) {
lvgl_lock();
app->updateTasks();
lvgl_unlock();
}
});
std::unique_ptr<Timer> memoryTimer;
std::unique_ptr<Timer> tasksTimer;
MemoryBarWidgets internalMemBar;
MemoryBarWidgets externalMemBar;
@@ -275,146 +258,204 @@ class SystemInfoApp final : public App {
bool hasExternalMem = false;
bool hasDataStorage = false;
bool hasSystemStorage = false;
void updateMemory() {
updateMemoryBar(internalMemBar, getHeapFree(), getHeapTotal());
if (hasExternalMem) {
updateMemoryBar(externalMemBar, getSpiFree(), getSpiTotal());
}
}
void updateStorage() {
#ifdef ESP_PLATFORM
uint64_t storage_total = 0;
uint64_t storage_free = 0;
if (hasDataStorage) {
if (esp_vfs_fat_info(file::MOUNT_POINT_DATA, &storage_total, &storage_free) == ESP_OK) {
updateMemoryBar(dataStorageBar, storage_free, storage_total);
}
}
std::string sdcard_path;
if (findFirstMountedSdCardPath(sdcard_path) && esp_vfs_fat_info(sdcard_path.c_str(), &storage_total, &storage_free) == ESP_OK) {
updateMemoryBar(sdcardStorageBar, storage_free, storage_total);
}
if (hasSystemStorage) {
if (esp_vfs_fat_info(file::MOUNT_POINT_SYSTEM, &storage_total, &storage_free) == ESP_OK) {
updateMemoryBar(systemStorageBar, storage_free, storage_total);
}
}
#endif
}
void updateTasks() {
#if configUSE_TRACE_FACILITY
if (tasksContainer) {
updateRtosTasks(tasksContainer); // Tasks tab: show state
}
#endif
}
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_style_pad_all(wrapper, 0, LV_STATE_DEFAULT);
auto* tabview = lv_tabview_create(wrapper);
lv_tabview_set_tab_bar_position(tabview, LV_DIR_LEFT);
auto tab_bar_width = 6 * lvgl_get_text_font_height(FONT_SIZE_DEFAULT);
lv_tabview_set_tab_bar_size(tabview, tab_bar_width);
// Create tabs
auto* memory_tab = createTab(tabview, "Memory");
auto* storage_tab = createTab(tabview, "Storage");
auto* tasks_tab = createTab(tabview, "Tasks");
auto* about_tab = createTab(tabview, "About");
// Memory tab content
internalMemBar = createMemoryBar(memory_tab, "Internal");
hasExternalMem = getSpiTotal() > 0;
if (hasExternalMem) {
externalMemBar = createMemoryBar(memory_tab, "External");
}
#ifdef ESP_PLATFORM
// Storage tab content
uint64_t storage_total = 0;
uint64_t storage_free = 0;
hasDataStorage = (esp_vfs_fat_info(file::MOUNT_POINT_DATA, &storage_total, &storage_free) == ESP_OK);
if (hasDataStorage) {
dataStorageBar = createMemoryBar(storage_tab, file::MOUNT_POINT_DATA);
}
std::string sdcard_path;
if (findFirstMountedSdCardPath(sdcard_path) && esp_vfs_fat_info(sdcard_path.c_str(), &storage_total, &storage_free) == ESP_OK) {
sdcardStorageBar = createMemoryBar(storage_tab, sdcard_path.c_str());
}
if (config::SHOW_SYSTEM_PARTITION) {
hasSystemStorage = (esp_vfs_fat_info(file::MOUNT_POINT_SYSTEM, &storage_total, &storage_free) == ESP_OK);
if (hasSystemStorage) {
systemStorageBar = createMemoryBar(storage_tab, file::MOUNT_POINT_SYSTEM);
}
}
#endif
#if configUSE_TRACE_FACILITY
// Tasks tab - container for dynamic updates
tasksContainer = lv_obj_create(tasks_tab);
lv_obj_set_size(tasksContainer, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(tasksContainer, 8, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(tasksContainer, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(tasksContainer, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_bg_opa(tasksContainer, 0, LV_STATE_DEFAULT);
#endif
// Build info
auto* tactility_version = lv_label_create(about_tab);
lv_label_set_text_fmt(tactility_version, "Tactility v%s", TT_VERSION);
#ifdef ESP_PLATFORM
auto* esp_idf_version = lv_label_create(about_tab);
lv_label_set_text_fmt(esp_idf_version, "ESP-IDF v%d.%d.%d", ESP_IDF_VERSION_MAJOR, ESP_IDF_VERSION_MINOR, ESP_IDF_VERSION_PATCH);
#endif
auto* device_vendor = lv_label_create(about_tab);
lv_label_set_text_fmt(device_vendor, "Hardware vendor: %s", CONFIG_TT_DEVICE_VENDOR);
auto* device_device_name = lv_label_create(about_tab);
lv_label_set_text_fmt(device_device_name, "Hardware model: %s", CONFIG_TT_DEVICE_NAME_SIMPLE);
// Initial updates
updateMemory();
updateStorage(); // Storage: one-time update on show (doesn't change frequently)
updateTasks();
// Start timers (only run while app is visible, stopped in onHide)
memoryTimer.start(); // Memory: every 10s
tasksTimer.start(); // Tasks/CPU: every 15s
}
void onHide(AppContext& app) override {
memoryTimer.stop();
tasksTimer.stop();
}
};
extern const AppManifest manifest = {
.appId = "SystemInfo",
.appName = "System Info",
.appIcon = LVGL_ICON_SHARED_AREA_CHART,
.appCategory = Category::System,
.createApp = create<SystemInfoApp>
void updateMemory(Context* ctx) {
updateMemoryBar(ctx->internalMemBar, getHeapFree(), getHeapTotal());
if (ctx->hasExternalMem) {
updateMemoryBar(ctx->externalMemBar, getSpiFree(), getSpiTotal());
}
}
void updateStorage(Context* ctx) {
#ifdef ESP_PLATFORM
uint64_t storage_total = 0;
uint64_t storage_free = 0;
if (ctx->hasDataStorage) {
if (esp_vfs_fat_info(file::MOUNT_POINT_DATA, &storage_total, &storage_free) == ESP_OK) {
updateMemoryBar(ctx->dataStorageBar, storage_free, storage_total);
}
}
std::string sdcard_path;
if (findFirstMountedSdCardPath(sdcard_path) && esp_vfs_fat_info(sdcard_path.c_str(), &storage_total, &storage_free) == ESP_OK) {
updateMemoryBar(ctx->sdcardStorageBar, storage_free, storage_total);
}
if (ctx->hasSystemStorage) {
if (esp_vfs_fat_info(file::MOUNT_POINT_SYSTEM, &storage_total, &storage_free) == ESP_OK) {
updateMemoryBar(ctx->systemStorageBar, storage_free, storage_total);
}
}
#endif
}
void updateTasks(Context* ctx) {
#if configUSE_TRACE_FACILITY
if (ctx->tasksContainer) {
updateRtosTasks(ctx->tasksContainer); // Tasks tab: show state
}
#endif
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
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, "System Info");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_style_pad_all(wrapper, 0, LV_STATE_DEFAULT);
auto* tabview = lv_tabview_create(wrapper);
lv_tabview_set_tab_bar_position(tabview, LV_DIR_LEFT);
auto tab_bar_width = 6 * lvgl_get_text_font_height(FONT_SIZE_DEFAULT);
lv_tabview_set_tab_bar_size(tabview, tab_bar_width);
// Create tabs
auto* memory_tab = createTab(tabview, "Memory");
auto* storage_tab = createTab(tabview, "Storage");
auto* tasks_tab = createTab(tabview, "Tasks");
auto* about_tab = createTab(tabview, "About");
// Memory tab content
ctx->internalMemBar = createMemoryBar(memory_tab, "Internal");
ctx->hasExternalMem = getSpiTotal() > 0;
if (ctx->hasExternalMem) {
ctx->externalMemBar = createMemoryBar(memory_tab, "External");
}
#ifdef ESP_PLATFORM
// Storage tab content
uint64_t storage_total = 0;
uint64_t storage_free = 0;
ctx->hasDataStorage = (esp_vfs_fat_info(file::MOUNT_POINT_DATA, &storage_total, &storage_free) == ESP_OK);
if (ctx->hasDataStorage) {
ctx->dataStorageBar = createMemoryBar(storage_tab, file::MOUNT_POINT_DATA);
}
std::string sdcard_path;
if (findFirstMountedSdCardPath(sdcard_path) && esp_vfs_fat_info(sdcard_path.c_str(), &storage_total, &storage_free) == ESP_OK) {
ctx->sdcardStorageBar = createMemoryBar(storage_tab, sdcard_path.c_str());
}
if (config::SHOW_SYSTEM_PARTITION) {
ctx->hasSystemStorage = (esp_vfs_fat_info(file::MOUNT_POINT_SYSTEM, &storage_total, &storage_free) == ESP_OK);
if (ctx->hasSystemStorage) {
ctx->systemStorageBar = createMemoryBar(storage_tab, file::MOUNT_POINT_SYSTEM);
}
}
#endif
#if configUSE_TRACE_FACILITY
// Tasks tab - container for dynamic updates
ctx->tasksContainer = lv_obj_create(tasks_tab);
lv_obj_set_size(ctx->tasksContainer, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ctx->tasksContainer, 8, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ctx->tasksContainer, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(ctx->tasksContainer, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_bg_opa(ctx->tasksContainer, 0, LV_STATE_DEFAULT);
#endif
// Build info
auto* tactility_version = lv_label_create(about_tab);
lv_label_set_text_fmt(tactility_version, "Tactility v%s", TT_VERSION);
#ifdef ESP_PLATFORM
auto* esp_idf_version = lv_label_create(about_tab);
lv_label_set_text_fmt(esp_idf_version, "ESP-IDF v%d.%d.%d", ESP_IDF_VERSION_MAJOR, ESP_IDF_VERSION_MINOR, ESP_IDF_VERSION_PATCH);
#endif
auto* device_vendor = lv_label_create(about_tab);
lv_label_set_text_fmt(device_vendor, "Hardware vendor: %s", CONFIG_TT_DEVICE_VENDOR);
auto* device_device_name = lv_label_create(about_tab);
lv_label_set_text_fmt(device_device_name, "Hardware model: %s", CONFIG_TT_DEVICE_NAME_SIMPLE);
// Initial updates
updateMemory(ctx);
updateStorage(ctx); // Storage: one-time update on show (doesn't change frequently)
updateTasks(ctx);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
// Run for this app instance's whole lifetime (mirrors GpsSettings) - both timers keep the
// displayed values fresh regardless of whether the app is currently topmost.
ctx.memoryTimer = std::make_unique<Timer>(Timer::Type::Periodic, millis_to_ticks(10000), [&ctx] {
lvgl_lock();
updateMemory(&ctx);
lvgl_unlock();
});
ctx.tasksTimer = std::make_unique<Timer>(Timer::Type::Periodic, millis_to_ticks(15000), [&ctx] {
lvgl_lock();
updateTasks(&ctx);
lvgl_unlock();
});
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
ctx.memoryTimer->start(); // Memory: every 10s
ctx.tasksTimer->start(); // Tasks/CPU: every 15s
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
ctx.memoryTimer->stop();
ctx.tasksTimer->stop();
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "SystemInfo",
.name = "System Info",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace
@@ -1,175 +1,218 @@
#include <Tactility/RecursiveMutex.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/timedatesettings/TimeDateSettings.h>
#include <Tactility/app/timezone/TimeZone.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/settings/SystemSettings.h>
#include <Tactility/settings/Time.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <lvgl/icons/shared.h>
#include <lvgl/widgets/toolbar.h>
namespace tt::app::timedatesettings {
constexpr auto* TAG = "TimeDate";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
class TimeDateSettingsApp final : public App {
namespace {
RecursiveMutex mutex;
struct Context {
uint32_t appInstanceId;
lv_obj_t* timeZoneLabel = nullptr;
lv_obj_t* dateFormatDropdown = nullptr;
bool isShown = false;
uint32_t pendingTimeZoneDialogId = 0;
};
static void onTimeFormatChanged(lv_event_t* event) {
auto* widget = lv_event_get_target_obj(event);
bool show_24 = lv_obj_has_state(widget, LV_STATE_CHECKED);
settings::setTimeFormat24Hour(show_24);
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onTimeFormatChanged(lv_event_t* event) {
auto* widget = lv_event_get_target_obj(event);
bool show_24 = lv_obj_has_state(widget, LV_STATE_CHECKED);
settings::setTimeFormat24Hour(show_24);
}
void onTimeZonePressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->pendingTimeZoneDialogId = timezone::start(ctx->appInstanceId, true);
}
void onDateFormatChanged(lv_event_t* event) {
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
auto index = lv_dropdown_get_selected(dropdown);
const char* dateFormats[] = {"MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD", "YYYY/MM/DD"};
std::string selected_format = dateFormats[index];
settings::SystemSettings sysSettings;
if (settings::loadSystemSettings(sysSettings)) {
sysSettings.dateFormat = selected_format;
settings::saveSystemSettings(sysSettings);
}
}
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, "Time & Date");
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// 24-hour format toggle
auto* time_format_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(time_format_wrapper, LV_PCT(100));
lv_obj_set_height(time_format_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(time_format_wrapper, 8, 0);
lv_obj_set_style_border_width(time_format_wrapper, 0, 0);
auto* time_24h_label = lv_label_create(time_format_wrapper);
lv_label_set_text(time_24h_label, "24-hour format");
lv_obj_align(time_24h_label, LV_ALIGN_LEFT_MID, 4, 0);
auto* time_24h_switch = lv_switch_create(time_format_wrapper);
lv_obj_align(time_24h_switch, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(time_24h_switch, onTimeFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr);
if (settings::isTimeFormat24Hour()) {
lv_obj_add_state(time_24h_switch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(time_24h_switch, LV_STATE_CHECKED);
}
static void onTimeZonePressed(lv_event_t* event) {
timezone::start(true);
}
// Date format dropdown
static void onDateFormatChanged(lv_event_t* event) {
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(event));
auto index = lv_dropdown_get_selected(dropdown);
const char* dateFormats[] = {"MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD", "YYYY/MM/DD"};
std::string selected_format = dateFormats[index];
settings::SystemSettings sysSettings;
if (settings::loadSystemSettings(sysSettings)) {
sysSettings.dateFormat = selected_format;
settings::saveSystemSettings(sysSettings);
auto* date_format_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(date_format_wrapper, LV_PCT(100));
lv_obj_set_height(date_format_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(date_format_wrapper, 8, 0);
lv_obj_set_style_border_width(date_format_wrapper, 0, 0);
auto* date_format_label = lv_label_create(date_format_wrapper);
lv_label_set_text(date_format_label, "Date format");
lv_obj_align(date_format_label, LV_ALIGN_LEFT_MID, 4, 0);
ctx->dateFormatDropdown = lv_dropdown_create(date_format_wrapper);
lv_obj_set_width(ctx->dateFormatDropdown, 150);
lv_obj_align(ctx->dateFormatDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_dropdown_set_options(ctx->dateFormatDropdown, "MM/DD/YYYY\nDD/MM/YYYY\nYYYY-MM-DD\nYYYY/MM/DD");
settings::SystemSettings sysSettings;
if (settings::loadSystemSettings(sysSettings)) {
int index = 0;
if (sysSettings.dateFormat == "DD/MM/YYYY") index = 1;
else if (sysSettings.dateFormat == "YYYY-MM-DD") index = 2;
else if (sysSettings.dateFormat == "YYYY/MM/DD") index = 3;
lv_dropdown_set_selected(ctx->dateFormatDropdown, index);
}
lv_obj_add_event_cb(ctx->dateFormatDropdown, onDateFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr);
// Timezone selector
auto* timezone_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(timezone_wrapper, LV_PCT(100));
lv_obj_set_height(timezone_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timezone_wrapper, 8, 0);
lv_obj_set_style_border_width(timezone_wrapper, 0, 0);
auto* timezone_label = lv_label_create(timezone_wrapper);
lv_label_set_text(timezone_label, "Timezone");
lv_obj_align(timezone_label, LV_ALIGN_LEFT_MID, 4, 0);
auto* timezone_button = lv_button_create(timezone_wrapper);
lv_obj_set_width(timezone_button, 150);
lv_obj_align(timezone_button, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(timezone_button, onTimeZonePressed, LV_EVENT_SHORT_CLICKED, ctx);
ctx->timeZoneLabel = lv_label_create(timezone_button);
std::string timeZoneName = settings::getTimeZoneName();
if (timeZoneName.empty()) {
timeZoneName = "not set";
}
lv_obj_center(ctx->timeZoneLabel);
lv_label_set_text(ctx->timeZoneLabel, timeZoneName.c_str());
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// 24-hour format toggle
auto* time_format_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(time_format_wrapper, LV_PCT(100));
lv_obj_set_height(time_format_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(time_format_wrapper, 8, 0);
lv_obj_set_style_border_width(time_format_wrapper, 0, 0);
auto* time_24h_label = lv_label_create(time_format_wrapper);
lv_label_set_text(time_24h_label, "24-hour format");
lv_obj_align(time_24h_label, LV_ALIGN_LEFT_MID, 4, 0);
auto* time_24h_switch = lv_switch_create(time_format_wrapper);
lv_obj_align(time_24h_switch, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(time_24h_switch, onTimeFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr);
if (settings::isTimeFormat24Hour()) {
lv_obj_add_state(time_24h_switch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(time_24h_switch, LV_STATE_CHECKED);
}
// Date format dropdown
auto* date_format_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(date_format_wrapper, LV_PCT(100));
lv_obj_set_height(date_format_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(date_format_wrapper, 8, 0);
lv_obj_set_style_border_width(date_format_wrapper, 0, 0);
auto* date_format_label = lv_label_create(date_format_wrapper);
lv_label_set_text(date_format_label, "Date format");
lv_obj_align(date_format_label, LV_ALIGN_LEFT_MID, 4, 0);
dateFormatDropdown = lv_dropdown_create(date_format_wrapper);
lv_obj_set_width(dateFormatDropdown, 150);
lv_obj_align(dateFormatDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_dropdown_set_options(dateFormatDropdown, "MM/DD/YYYY\nDD/MM/YYYY\nYYYY-MM-DD\nYYYY/MM/DD");
settings::SystemSettings sysSettings;
if (settings::loadSystemSettings(sysSettings)) {
int index = 0;
if (sysSettings.dateFormat == "DD/MM/YYYY") index = 1;
else if (sysSettings.dateFormat == "YYYY-MM-DD") index = 2;
else if (sysSettings.dateFormat == "YYYY/MM/DD") index = 3;
lv_dropdown_set_selected(dateFormatDropdown, index);
}
lv_obj_add_event_cb(dateFormatDropdown, onDateFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr);
// Timezone selector
auto* timezone_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_width(timezone_wrapper, LV_PCT(100));
lv_obj_set_height(timezone_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(timezone_wrapper, 8, 0);
lv_obj_set_style_border_width(timezone_wrapper, 0, 0);
auto* timezone_label = lv_label_create(timezone_wrapper);
lv_label_set_text(timezone_label, "Timezone");
lv_obj_align(timezone_label, LV_ALIGN_LEFT_MID, 4, 0);
auto* timezone_button = lv_button_create(timezone_wrapper);
lv_obj_set_width(timezone_button, 150);
lv_obj_align(timezone_button, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(timezone_button, onTimeZonePressed, LV_EVENT_SHORT_CLICKED, nullptr);
timeZoneLabel = lv_label_create(timezone_button);
std::string timeZoneName = settings::getTimeZoneName();
if (timeZoneName.empty()) {
timeZoneName = "not set";
}
lv_obj_center(timeZoneLabel);
lv_label_set_text(timeZoneLabel, timeZoneName.c_str());
isShown = true;
}
void onHide(AppContext& app) override {
isShown = false;
}
void onResult(AppContext& app, LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) override {
if (result == Result::Ok && bundle != nullptr) {
const auto name = timezone::getResultName(*bundle);
const auto code = timezone::getResultCode(*bundle);
LOG_I(TAG, "Result name=%s code=%s", name.c_str(), code.c_str());
// onShow() may not have (re)created the widgets yet: onResult() runs synchronously
// on the loader thread and can race ahead of the async gui-task redraw.
if (!name.empty() && lvgl_try_lock(100 / portTICK_PERIOD_MS)) {
if (isShown) {
lv_label_set_text(timeZoneLabel, name.c_str());
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
case APP_EVENT_RESULT:
if (event.result.launch_id == ctx.pendingTimeZoneDialogId) {
ctx.pendingTimeZoneDialogId = 0;
if (event.result.result == 0 /* Ok */) {
const auto name = timezone::getLastName();
LOG_I(TAG, "Result name=%s code=%s", name.c_str(), timezone::getLastCode().c_str());
if (!name.empty()) {
lvgl_lock();
lv_label_set_text(ctx.timeZoneLabel, name.c_str());
lvgl_unlock();
}
}
}
lvgl_unlock();
}
app_manager_stop(event.result.launch_id);
break;
default:
break;
}
}
};
extern const AppManifest manifest = {
.appId = "TimeDateSettings",
.appName = "Time & Date",
.appIcon = LVGL_ICON_SHARED_CALENDAR_MONTH,
.appCategory = Category::Settings,
.createApp = create<TimeDateSettingsApp>
};
window_manager_remove(window);
app_event_unsubscribe(&sub);
LaunchId start() {
return app::start(manifest.appId);
return 0;
}
} // namespace
uint32_t start() {
uint32_t instanceId = 0;
app_manager_start(manifest.id, &instanceId);
return instanceId;
}
extern const ::AppManifest manifest = {
.id = "TimeDateSettings",
.name = "Time & Date",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace tt::app::timedatesettings
+243 -208
View File
@@ -1,19 +1,23 @@
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/timezone/TimeZone.h>
#include <Tactility/LogMessages.h>
#include <Tactility/MountPoints.h>
#include <Tactility/Mutex.h>
#include <Tactility/StringUtils.h>
#include <Tactility/Timer.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/settings/Time.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <lvgl/icons/shared.h>
#include <lvgl/fonts.h>
#include <lvgl/widgets/toolbar.h>
#include <memory>
@@ -21,18 +25,39 @@ namespace tt::app::timezone {
constexpr auto* TAG = "TimeZone";
constexpr auto* RESULT_BUNDLE_CODE_INDEX = "code";
constexpr auto* RESULT_BUNDLE_NAME_INDEX = "name";
constexpr auto* PARAM_SAVE_TIME_ZONE = "saveTimeZone";
extern const ::AppManifest manifest;
extern const AppManifest manifest;
namespace {
struct TimeZoneEntry {
std::string name;
std::string code;
};
static bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) {
struct Context {
uint32_t appInstanceId;
Mutex mutex;
std::vector<TimeZoneEntry> entries;
std::unique_ptr<Timer> updateTimer;
lv_obj_t* listWidget = nullptr;
lv_obj_t* filterTextareaWidget = nullptr;
bool saveTimeZone = false;
// The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this
// is a plain (non-atomic) field safely shared between the LVGL thread (writer, before
// emitting APP_EVENT_CLOSE) and this app's own thread (reader, after waking from it).
int32_t result = 1; // Cancelled - safety-net default if closed without picking a time zone
};
// The last picked name/code. Static rather than per-instance: simple, and in practice only one
// TimeZone dialog is ever open at a time. Written on the LVGL thread (the item-selected
// callback, before emitting APP_EVENT_CLOSE); read by the parent via getLastName()/getLastCode()
// after receiving that event - safe without a lock for the same reason Context::result is (see
// AlertDialog.cpp).
std::string lastName;
std::string lastCode;
bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) {
std::string partial_strip = input.substr(1, input.size() - 3);
auto first_end_quote = partial_strip.find('"');
if (first_end_quote == std::string::npos) {
@@ -44,215 +69,225 @@ static bool parseEntry(const std::string& input, std::string& outName, std::stri
}
}
// region Result
std::string getResultName(const Bundle& bundle) {
std::string result;
bundle.optString(RESULT_BUNDLE_NAME_INDEX, result);
return result;
}
std::string getResultCode(const Bundle& bundle) {
std::string result;
bundle.optString(RESULT_BUNDLE_CODE_INDEX, result);
return result;
}
void setResultName(Bundle& bundle, const std::string& name) {
bundle.putString(RESULT_BUNDLE_NAME_INDEX, name);
}
void setResultCode(Bundle& bundle, const std::string& code) {
bundle.putString(RESULT_BUNDLE_CODE_INDEX, code);
}
// endregion
class TimeZoneApp final : public App {
Mutex mutex;
std::vector<TimeZoneEntry> entries;
std::unique_ptr<Timer> updateTimer;
lv_obj_t* listWidget = nullptr;
lv_obj_t* filterTextareaWidget = nullptr;
bool saveTimeZone = false;
static void onTextareaValueChangedCallback(lv_event_t* e) {
auto* app = (TimeZoneApp*)lv_event_get_user_data(e);
app->onTextareaValueChanged(e);
}
void onTextareaValueChanged(lv_event_t* e) {
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
if (updateTimer->isRunning()) {
updateTimer->stop();
}
updateTimer->start();
mutex.unlock();
void onTextareaValueChanged(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
if (ctx->updateTimer->isRunning()) {
ctx->updateTimer->stop();
}
ctx->updateTimer->start();
ctx->mutex.unlock();
}
}
static void onListItemSelectedCallback(lv_event_t* e) {
auto index = reinterpret_cast<std::size_t>(lv_event_get_user_data(e));
auto app = std::static_pointer_cast<TimeZoneApp>(getCurrentApp());
assert(app != nullptr);
app->onListItemSelected(index);
}
void onListItemSelected(std::size_t index) {
void createListItem(Context* ctx, lv_obj_t* list, const std::string& title, size_t index) {
auto* btn = lv_list_add_button(list, nullptr, title.c_str());
struct ButtonContext {
Context* ctx;
size_t index;
};
auto* buttonCtx = new ButtonContext { ctx, index };
lv_obj_add_event_cb(btn, [](lv_event_t* e) {
auto* buttonCtx = static_cast<ButtonContext*>(lv_event_get_user_data(e));
delete buttonCtx;
}, LV_EVENT_DELETE, buttonCtx);
lv_obj_add_event_cb(btn, [](lv_event_t* e) {
auto* buttonCtx = static_cast<ButtonContext*>(lv_event_get_user_data(e));
auto* ctx = buttonCtx->ctx;
auto index = buttonCtx->index;
LOG_I(TAG, "Selected item at index %d", (int)index);
auto& entry = entries[index];
auto& entry = ctx->entries[index];
if (saveTimeZone) {
if (ctx->saveTimeZone) {
settings::setTimeZone(entry.name, entry.code);
}
auto bundle = std::make_unique<Bundle>();
setResultName(*bundle, entry.name);
setResultCode(*bundle, entry.code);
lastName = entry.name;
lastCode = entry.code;
setResult(Result::Ok, std::move(bundle));
stop(manifest.appId);
}
static void createListItem(lv_obj_t* list, const std::string& title, size_t index) {
auto* btn = lv_list_add_button(list, nullptr, title.c_str());
lv_obj_add_event_cb(btn, &onListItemSelectedCallback, LV_EVENT_SHORT_CLICKED, (void*)index);
}
void readTimeZones(std::string filter) {
auto path = std::string(file::MOUNT_POINT_SYSTEM) + "/timezones.csv";
auto* file = fopen(path.c_str(), "rb");
if (file == nullptr) {
LOG_E(TAG, "Failed to open %s", path.c_str());
return;
}
char line[96];
std::string name;
std::string code;
uint32_t count = 0;
std::vector<TimeZoneEntry> new_entries;
while (fgets(line, 96, file)) {
if (parseEntry(line, name, code)) {
if (string::lowercase(name).find(filter) != std::string::npos) {
count++;
new_entries.push_back({.name = name, .code = code});
// Safety guard
if (count > 50) {
// TODO: Show warning that we're not displaying a complete list
break;
}
}
} else {
LOG_E(TAG, "Parse error at line %llu", count);
}
}
fclose(file);
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
entries = std::move(new_entries);
mutex.unlock();
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
}
LOG_I(TAG, "Processed %llu entries", count);
}
void updateList() {
if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) {
std::string filter = string::lowercase(std::string(lv_textarea_get_text(filterTextareaWidget)));
lvgl_unlock();
readTimeZones(filter);
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL");
return;
}
if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) {
if (mutex.lock(100 / portTICK_PERIOD_MS)) {
lv_obj_clean(listWidget);
uint32_t index = 0;
for (auto& entry : entries) {
createListItem(listWidget, entry.name, index);
index++;
}
mutex.unlock();
}
lvgl_unlock();
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL");
}
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
auto* search_wrapper = lv_obj_create(parent);
lv_obj_set_size(search_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(search_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(search_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
lv_obj_set_style_pad_all(search_wrapper, 0, 0);
lv_obj_set_style_border_width(search_wrapper, 0, 0);
auto* icon = lv_image_create(search_wrapper);
lv_obj_set_style_margin_left(icon, 8, 0);
lv_obj_set_style_image_recolor_opa(icon, 255, 0);
lv_obj_set_style_image_recolor(icon, lv_theme_get_color_primary(parent), 0);
lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_font(), LV_STATE_DEFAULT);
lv_image_set_src(icon, LVGL_ICON_SHARED_SEARCH);
auto* textarea = lv_textarea_create(search_wrapper);
lv_textarea_set_placeholder_text(textarea, "e.g. Europe/Amsterdam");
lv_textarea_set_one_line(textarea, true);
lv_obj_add_event_cb(textarea, onTextareaValueChangedCallback, LV_EVENT_VALUE_CHANGED, this);
filterTextareaWidget = textarea;
lv_obj_set_flex_grow(textarea, 1);
auto* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_style_border_width(list, 0, 0);
listWidget = list;
}
void onCreate(AppContext& app) override {
auto parameters = app.getParameters();
if (parameters != nullptr) {
parameters->optBool(PARAM_SAVE_TIME_ZONE, saveTimeZone);
}
updateTimer = std::make_unique<Timer>(Timer::Type::Once, 500 / portTICK_PERIOD_MS, [this] {
updateList();
});
}
};
extern const AppManifest manifest = {
.appId = "TimeZone",
.appName = "Select Time zone",
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<TimeZoneApp>
};
LaunchId start(bool saveTimeZone) {
auto bundle = std::make_shared<Bundle>();
bundle->putBool(PARAM_SAVE_TIME_ZONE, saveTimeZone);
return app::start(manifest.appId, bundle);
ctx->result = 0; // Ok
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}, LV_EVENT_SHORT_CLICKED, buttonCtx);
}
void readTimeZones(Context* ctx, std::string filter) {
auto path = std::string(file::MOUNT_POINT_SYSTEM) + "/timezones.csv";
auto* file = fopen(path.c_str(), "rb");
if (file == nullptr) {
LOG_E(TAG, "Failed to open %s", path.c_str());
return;
}
char line[96];
std::string name;
std::string code;
uint32_t count = 0;
std::vector<TimeZoneEntry> new_entries;
while (fgets(line, 96, file)) {
if (parseEntry(line, name, code)) {
if (string::lowercase(name).find(filter) != std::string::npos) {
count++;
new_entries.push_back({.name = name, .code = code});
// Safety guard
if (count > 50) {
// TODO: Show warning that we're not displaying a complete list
break;
}
}
} else {
LOG_E(TAG, "Parse error at line %llu", count);
}
}
fclose(file);
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
ctx->entries = std::move(new_entries);
ctx->mutex.unlock();
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
}
LOG_I(TAG, "Processed %llu entries", count);
}
void updateList(Context* ctx) {
if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) {
std::string filter = string::lowercase(std::string(lv_textarea_get_text(ctx->filterTextareaWidget)));
lvgl_unlock();
readTimeZones(ctx, filter);
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL");
return;
}
if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) {
if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) {
lv_obj_clean(ctx->listWidget);
uint32_t index = 0;
for (auto& entry : ctx->entries) {
createListItem(ctx, ctx->listWidget, entry.name, index);
index++;
}
ctx->mutex.unlock();
}
lvgl_unlock();
} else {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL");
}
}
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
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, "Select Time zone");
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
auto* search_wrapper = lv_obj_create(parent);
lv_obj_set_size(search_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(search_wrapper, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(search_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START);
lv_obj_set_style_pad_all(search_wrapper, 0, 0);
lv_obj_set_style_border_width(search_wrapper, 0, 0);
auto* icon = lv_image_create(search_wrapper);
lv_obj_set_style_margin_left(icon, 8, 0);
lv_obj_set_style_image_recolor_opa(icon, 255, 0);
lv_obj_set_style_image_recolor(icon, lv_theme_get_color_primary(parent), 0);
lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_font(), LV_STATE_DEFAULT);
lv_image_set_src(icon, LVGL_ICON_SHARED_SEARCH);
auto* textarea = lv_textarea_create(search_wrapper);
lv_textarea_set_placeholder_text(textarea, "e.g. Europe/Amsterdam");
lv_textarea_set_one_line(textarea, true);
lv_obj_add_event_cb(textarea, onTextareaValueChanged, LV_EVENT_VALUE_CHANGED, ctx);
ctx->filterTextareaWidget = textarea;
lv_obj_set_flex_grow(textarea, 1);
auto* list = lv_list_create(parent);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_style_border_width(list, 0, 0);
ctx->listWidget = list;
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
// argv layout: [0]="1"/"0" (saveTimeZone).
Context ctx;
ctx.appInstanceId = appInstanceId;
ctx.saveTimeZone = argc > 0 && argv[0][0] == '1';
ctx.updateTimer = std::make_unique<Timer>(Timer::Type::Once, 500 / portTICK_PERIOD_MS, [&ctx] {
updateList(&ctx);
});
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
ctx.updateTimer->start();
while (true) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
if (event.type == APP_EVENT_CLOSE) {
app_manager_finish(appInstanceId); // no-op: modal children never supersede anything
break;
}
}
ctx.updateTimer->stop();
window_manager_remove(window);
app_event_unsubscribe(&sub);
return ctx.result;
}
} // namespace
uint32_t start(uint32_t callerAppInstanceId, bool saveTimeZone) {
const char* argv[] = { saveTimeZone ? "1" : "0" };
uint32_t instanceId = 0;
app_manager_start_for_result(manifest.id, callerAppInstanceId, 1, argv, &instanceId);
return instanceId;
}
std::string getLastName() {
return lastName;
}
std::string getLastCode() {
return lastCode;
}
extern const ::AppManifest manifest = {
.id = "TimeZone",
.name = "Select Time zone",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
}
@@ -5,6 +5,12 @@
#include <Tactility/Tactility.h>
#include <Tactility/settings/TouchCalibrationSettings.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <lvgl/devices/pointer.h>
@@ -16,20 +22,19 @@ namespace tt::app::touchcalibration {
constexpr auto* TAG = "TouchCalibration";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
LaunchId start() {
return app::start(manifest.appId);
}
namespace {
class TouchCalibrationApp final : public App {
constexpr int32_t TARGET_MARGIN = 24;
static constexpr int32_t TARGET_MARGIN = 24;
struct Sample {
uint16_t x;
uint16_t y;
};
struct Sample {
uint16_t x;
uint16_t y;
};
struct Context {
uint32_t appInstanceId;
Sample samples[4] = {};
uint8_t sampleCount = 0;
@@ -39,227 +44,254 @@ class TouchCalibrationApp final : public App {
lv_obj_t* target = nullptr;
lv_obj_t* titleLabel = nullptr;
lv_obj_t* hintLabel = nullptr;
};
static void onPress(lv_event_t* event) {
auto* self = static_cast<TouchCalibrationApp*>(lv_event_get_user_data(event));
if (self != nullptr) {
self->onPressInternal(event);
}
lv_point_t getTargetPoint(uint8_t index, lv_coord_t width, lv_coord_t height) {
switch (index) {
case 0:
return {.x = TARGET_MARGIN, .y = TARGET_MARGIN};
case 1:
return {.x = width - TARGET_MARGIN, .y = TARGET_MARGIN};
case 2:
return {.x = width - TARGET_MARGIN, .y = height - TARGET_MARGIN};
default:
return {.x = TARGET_MARGIN, .y = height - TARGET_MARGIN};
}
}
void updateUi(Context* ctx) {
if (ctx->target == nullptr || ctx->root == nullptr || ctx->titleLabel == nullptr || ctx->hintLabel == nullptr) {
return;
}
static lv_point_t getTargetPoint(uint8_t index, lv_coord_t width, lv_coord_t height) {
switch (index) {
case 0:
return {.x = TARGET_MARGIN, .y = TARGET_MARGIN};
case 1:
return {.x = width - TARGET_MARGIN, .y = TARGET_MARGIN};
case 2:
return {.x = width - TARGET_MARGIN, .y = height - TARGET_MARGIN};
default:
return {.x = TARGET_MARGIN, .y = height - TARGET_MARGIN};
}
const auto width = lv_obj_get_content_width(ctx->root);
const auto height = lv_obj_get_content_height(ctx->root);
if (ctx->sampleCount < 4) {
const auto point = getTargetPoint(ctx->sampleCount, width, height);
lv_obj_set_pos(ctx->target, point.x - 14, point.y - 14);
lv_label_set_text(ctx->titleLabel, "Touchscreen Calibration");
lv_label_set_text_fmt(ctx->hintLabel, "Tap target %u/4", static_cast<unsigned>(ctx->sampleCount + 1));
}
}
// Drives the on-screen outcome text/state; the actual result (Ok/Error) is reported to the
// caller from onPress() below, via ctx->calibrationApplied, once the user taps to dismiss.
void finishCalibration(Context* ctx) {
const int32_t xLow = (static_cast<int32_t>(ctx->samples[0].x) + static_cast<int32_t>(ctx->samples[3].x)) / 2;
const int32_t xHigh = (static_cast<int32_t>(ctx->samples[1].x) + static_cast<int32_t>(ctx->samples[2].x)) / 2;
const int32_t yLow = (static_cast<int32_t>(ctx->samples[0].y) + static_cast<int32_t>(ctx->samples[1].y)) / 2;
const int32_t yHigh = (static_cast<int32_t>(ctx->samples[2].y) + static_cast<int32_t>(ctx->samples[3].y)) / 2;
// Targets sit TARGET_MARGIN in from each edge (see getTargetPoint()), not at the screen
// edges themselves - xLow/xHigh/yLow/yHigh are raw samples at those inset positions, not
// at 0/width or 0/height. Extrapolate them out to the true edges so the saved range (which
// lvgl_pointer.h maps onto the full [0, resolution) display range) lines up correctly
// across the whole screen instead of being off by a margin's worth of scale and offset.
const auto width = lv_obj_get_content_width(ctx->root);
const auto height = lv_obj_get_content_height(ctx->root);
const int32_t xSpan = static_cast<int32_t>(width) - 2 * TARGET_MARGIN;
const int32_t ySpan = static_cast<int32_t>(height) - 2 * TARGET_MARGIN;
if (xSpan <= 0 || ySpan <= 0) {
lv_label_set_text(ctx->titleLabel, "Calibration Failed");
lv_label_set_text(ctx->hintLabel, "Screen too small. Tap to close.");
lv_obj_add_flag(ctx->target, LV_OBJ_FLAG_HIDDEN);
return;
}
void updateUi() {
if (target == nullptr || root == nullptr || titleLabel == nullptr || hintLabel == nullptr) {
return;
}
const int32_t xMin = xLow - (xHigh - xLow) * TARGET_MARGIN / xSpan;
const int32_t xMax = xHigh + (xHigh - xLow) * TARGET_MARGIN / xSpan;
const int32_t yMin = yLow - (yHigh - yLow) * TARGET_MARGIN / ySpan;
const int32_t yMax = yHigh + (yHigh - yLow) * TARGET_MARGIN / ySpan;
const auto width = lv_obj_get_content_width(root);
const auto height = lv_obj_get_content_height(root);
settings::touch::TouchCalibrationSettings settings = settings::touch::getDefault();
settings.enabled = true;
settings.xMin = xMin;
settings.xMax = xMax;
settings.yMin = yMin;
settings.yMax = yMax;
if (sampleCount < 4) {
const auto point = getTargetPoint(sampleCount, width, height);
lv_obj_set_pos(target, point.x - 14, point.y - 14);
lv_label_set_text(titleLabel, "Touchscreen Calibration");
lv_label_set_text_fmt(hintLabel, "Tap target %u/4", static_cast<unsigned>(sampleCount + 1));
}
if (!settings::touch::isValid(settings)) {
lv_label_set_text(ctx->titleLabel, "Calibration Failed");
lv_label_set_text(ctx->hintLabel, "Range invalid. Tap to close.");
lv_obj_add_flag(ctx->target, LV_OBJ_FLAG_HIDDEN);
return;
}
void finishCalibration() {
const int32_t xLow = (static_cast<int32_t>(samples[0].x) + static_cast<int32_t>(samples[3].x)) / 2;
const int32_t xHigh = (static_cast<int32_t>(samples[1].x) + static_cast<int32_t>(samples[2].x)) / 2;
const int32_t yLow = (static_cast<int32_t>(samples[0].y) + static_cast<int32_t>(samples[1].y)) / 2;
const int32_t yHigh = (static_cast<int32_t>(samples[2].y) + static_cast<int32_t>(samples[3].y)) / 2;
if (!settings::touch::save(settings)) {
lv_label_set_text(ctx->titleLabel, "Calibration Failed");
lv_label_set_text(ctx->hintLabel, "Unable to save settings. Tap to close.");
lv_obj_add_flag(ctx->target, LV_OBJ_FLAG_HIDDEN);
return;
}
// Targets sit TARGET_MARGIN in from each edge (see getTargetPoint()), not at the screen
// edges themselves - xLow/xHigh/yLow/yHigh are raw samples at those inset positions, not
// at 0/width or 0/height. Extrapolate them out to the true edges so the saved range (which
// lvgl_pointer.h maps onto the full [0, resolution) display range) lines up correctly
// across the whole screen instead of being off by a margin's worth of scale and offset.
const auto width = lv_obj_get_content_width(root);
const auto height = lv_obj_get_content_height(root);
const int32_t xSpan = static_cast<int32_t>(width) - 2 * TARGET_MARGIN;
const int32_t ySpan = static_cast<int32_t>(height) - 2 * TARGET_MARGIN;
LvglPointerCalibration calibration = {
.x_min = xMin,
.x_max = xMax,
.y_min = yMin,
.y_max = yMax,
};
lvgl_lock();
auto* indev = lvgl_pointer_get_default();
if (indev != nullptr) {
lvgl_pointer_set_calibration(indev, &calibration);
}
lvgl_unlock();
ctx->calibrationApplied = true;
if (xSpan <= 0 || ySpan <= 0) {
lv_label_set_text(titleLabel, "Calibration Failed");
lv_label_set_text(hintLabel, "Screen too small. Tap to close.");
lv_obj_add_flag(target, LV_OBJ_FLAG_HIDDEN);
setResult(Result::Error);
return;
}
LOG_I(TAG, "Saved calibration x=[%d, %d] y=[%d, %d]", xMin, xMax, yMin, yMax);
lv_label_set_text(ctx->titleLabel, "Calibration Complete");
lv_label_set_text(ctx->hintLabel, "Touch anywhere to continue.");
lv_obj_add_flag(ctx->target, LV_OBJ_FLAG_HIDDEN);
}
const int32_t xMin = xLow - (xHigh - xLow) * TARGET_MARGIN / xSpan;
const int32_t xMax = xHigh + (xHigh - xLow) * TARGET_MARGIN / xSpan;
const int32_t yMin = yLow - (yHigh - yLow) * TARGET_MARGIN / ySpan;
const int32_t yMax = yHigh + (yHigh - yLow) * TARGET_MARGIN / ySpan;
void onPress(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* indev = lv_event_get_indev(event);
if (indev == nullptr) {
return;
}
settings::touch::TouchCalibrationSettings settings = settings::touch::getDefault();
settings.enabled = true;
settings.xMin = xMin;
settings.xMax = xMax;
settings.yMin = yMin;
settings.yMax = yMax;
lv_point_t point = {0, 0};
lv_indev_get_point(indev, &point);
if (!settings::touch::isValid(settings)) {
lv_label_set_text(titleLabel, "Calibration Failed");
lv_label_set_text(hintLabel, "Range invalid. Tap to close.");
lv_obj_add_flag(target, LV_OBJ_FLAG_HIDDEN);
setResult(Result::Error);
return;
}
if (!settings::touch::save(settings)) {
lv_label_set_text(titleLabel, "Calibration Failed");
lv_label_set_text(hintLabel, "Unable to save settings. Tap to close.");
lv_obj_add_flag(target, LV_OBJ_FLAG_HIDDEN);
setResult(Result::Error);
return;
}
LvglPointerCalibration calibration = {
.x_min = xMin,
.x_max = xMax,
.y_min = yMin,
.y_max = yMax,
if (ctx->sampleCount < 4) {
ctx->samples[ctx->sampleCount] = {
.x = static_cast<uint16_t>(std::max(static_cast<lv_coord_t>(0), point.x)),
.y = static_cast<uint16_t>(std::max(static_cast<lv_coord_t>(0), point.y)),
};
lvgl_lock();
auto* indev = lvgl_pointer_get_default();
if (indev != nullptr) {
lvgl_pointer_set_calibration(indev, &calibration);
}
lvgl_unlock();
calibrationApplied = true;
ctx->sampleCount++;
LOG_I(TAG, "Saved calibration x=[%d, %d] y=[%d, %d]", xMin, xMax, yMin, yMax);
lv_label_set_text(titleLabel, "Calibration Complete");
lv_label_set_text(hintLabel, "Touch anywhere to continue.");
lv_obj_add_flag(target, LV_OBJ_FLAG_HIDDEN);
setResult(Result::Ok);
if (ctx->sampleCount < 4) {
updateUi(ctx);
} else {
finishCalibration(ctx);
}
return;
}
void onPressInternal(lv_event_t* event) {
auto* indev = lv_event_get_indev(event);
if (indev == nullptr) {
return;
// Async, non-blocking - must NOT call app_manager_stop()/app_manager_finish() directly
// here: this callback runs ON the LVGL task, and app-lifecycle transitions must happen on
// this app's own thread (woken up via app_event_await() below). The result (Ok/Error) is
// reported by appMain() itself when it returns, based on ctx.calibrationApplied.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
lv_obj_set_style_bg_color(parent, lv_color_black(), LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(parent, 0, LV_STATE_DEFAULT);
lv_obj_set_style_radius(parent, 0, LV_STATE_DEFAULT);
ctx->root = lv_obj_create(parent);
lv_obj_set_size(ctx->root, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_opa(ctx->root, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ctx->root, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(ctx->root, 0, LV_STATE_DEFAULT);
ctx->titleLabel = lv_label_create(ctx->root);
lv_obj_align(ctx->titleLabel, LV_ALIGN_TOP_MID, 0, 14);
lv_obj_set_style_text_color(ctx->titleLabel, lv_color_white(), LV_STATE_DEFAULT);
lv_label_set_text(ctx->titleLabel, "Touchscreen Calibration");
ctx->hintLabel = lv_label_create(ctx->root);
lv_obj_align(ctx->hintLabel, LV_ALIGN_BOTTOM_MID, 0, -14);
lv_obj_set_style_text_color(ctx->hintLabel, lv_color_white(), LV_STATE_DEFAULT);
lv_label_set_text(ctx->hintLabel, "Tap target 1/4");
ctx->target = lv_button_create(ctx->root);
lv_obj_set_size(ctx->target, 28, 28);
lv_obj_set_style_radius(ctx->target, LV_RADIUS_CIRCLE, LV_STATE_DEFAULT);
lv_obj_set_style_bg_color(ctx->target, lv_palette_main(LV_PALETTE_RED), LV_STATE_DEFAULT);
// Ensure root receives all presses for sampling.
lv_obj_remove_flag(ctx->target, LV_OBJ_FLAG_CLICKABLE);
auto* targetLabel = lv_label_create(ctx->target);
lv_label_set_text(targetLabel, "+");
lv_obj_center(targetLabel);
lv_obj_add_flag(ctx->root, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(ctx->root, onPress, LV_EVENT_PRESSED, ctx);
updateUi(ctx);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
// Clear any active calibration so the taps sampled below are raw, uncalibrated coordinates.
lvgl_lock();
auto* startIndev = lvgl_pointer_get_default();
if (startIndev != nullptr) {
lvgl_pointer_set_calibration(startIndev, nullptr);
}
lvgl_unlock();
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
lv_point_t point = {0, 0};
lv_indev_get_point(indev, &point);
if (sampleCount < 4) {
samples[sampleCount] = {
.x = static_cast<uint16_t>(std::max(static_cast<lv_coord_t>(0), point.x)),
.y = static_cast<uint16_t>(std::max(static_cast<lv_coord_t>(0), point.y)),
};
sampleCount++;
if (sampleCount < 4) {
updateUi();
} else {
finishCalibration();
}
return;
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
stop(manifest.appId);
}
public:
void onCreate(AppContext& app) override {
(void)app;
// Clear any active calibration so the taps sampled below are raw, uncalibrated coordinates.
lvgl_lock();
auto* indev = lvgl_pointer_get_default();
if (indev != nullptr) {
lvgl_pointer_set_calibration(indev, nullptr);
}
lvgl_unlock();
}
void onDestroy(AppContext& app) override {
(void)app;
// finishCalibration() already applied a new calibration on success. On cancel/failure,
// restore whatever calibration was on disk before onCreate() cleared it above.
if (calibrationApplied) {
return;
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
// finishCalibration() already applied a new calibration on success. On cancel/failure,
// restore whatever calibration was on disk before the block above cleared it.
if (!ctx.calibrationApplied) {
settings::touch::TouchCalibrationSettings settings;
lvgl_lock();
auto* indev = lvgl_pointer_get_default();
if (indev != nullptr && settings::touch::load(settings) && settings.enabled && settings::touch::isValid(settings)) {
auto* endIndev = lvgl_pointer_get_default();
if (endIndev != nullptr && settings::touch::load(settings) && settings.enabled && settings::touch::isValid(settings)) {
LvglPointerCalibration calibration = {
.x_min = settings.xMin,
.x_max = settings.xMax,
.y_min = settings.yMin,
.y_max = settings.yMax,
};
lvgl_pointer_set_calibration(indev, &calibration);
lvgl_pointer_set_calibration(endIndev, &calibration);
}
lvgl_unlock();
}
void onShow(AppContext& app, lv_obj_t* parent) override {
(void)app;
return ctx.calibrationApplied ? 0 : 2; // Ok : Error
}
lv_obj_set_style_bg_color(parent, lv_color_black(), LV_STATE_DEFAULT);
lv_obj_set_style_bg_opa(parent, LV_OPA_COVER, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(parent, 0, LV_STATE_DEFAULT);
lv_obj_set_style_radius(parent, 0, LV_STATE_DEFAULT);
} // namespace
root = lv_obj_create(parent);
lv_obj_set_size(root, LV_PCT(100), LV_PCT(100));
lv_obj_set_style_bg_opa(root, LV_OPA_TRANSP, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(root, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(root, 0, LV_STATE_DEFAULT);
uint32_t start(uint32_t callerAppInstanceId) {
uint32_t instanceId = 0;
app_manager_start_for_result(manifest.id, callerAppInstanceId, 0, nullptr, &instanceId);
return instanceId;
}
titleLabel = lv_label_create(root);
lv_obj_align(titleLabel, LV_ALIGN_TOP_MID, 0, 14);
lv_obj_set_style_text_color(titleLabel, lv_color_white(), LV_STATE_DEFAULT);
lv_label_set_text(titleLabel, "Touchscreen Calibration");
hintLabel = lv_label_create(root);
lv_obj_align(hintLabel, LV_ALIGN_BOTTOM_MID, 0, -14);
lv_obj_set_style_text_color(hintLabel, lv_color_white(), LV_STATE_DEFAULT);
lv_label_set_text(hintLabel, "Tap target 1/4");
target = lv_button_create(root);
lv_obj_set_size(target, 28, 28);
lv_obj_set_style_radius(target, LV_RADIUS_CIRCLE, LV_STATE_DEFAULT);
lv_obj_set_style_bg_color(target, lv_palette_main(LV_PALETTE_RED), LV_STATE_DEFAULT);
// Ensure root receives all presses for sampling.
lv_obj_remove_flag(target, LV_OBJ_FLAG_CLICKABLE);
auto* targetLabel = lv_label_create(target);
lv_label_set_text(targetLabel, "+");
lv_obj_center(targetLabel);
lv_obj_add_flag(root, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(root, onPress, LV_EVENT_PRESSED, this);
updateUi();
}
};
extern const AppManifest manifest = {
.appId = "TouchCalibration",
.appName = "Touch Calibration",
.appCategory = Category::Settings,
.appFlags = AppManifest::Flags::HideStatusBar,
.createApp = create<TouchCalibrationApp>
extern const ::AppManifest manifest = {
.id = "TouchCalibration",
.name = "Touch Calibration",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace tt::app::touchcalibration
@@ -2,18 +2,25 @@
#include <lvgl/devices/device_context.h>
#include <lvgl/devices/trackball.h>
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/drivers/trackball.h>
#include <Tactility/Assets.h>
#include <Tactility/settings/TrackballSettings.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/Tactility.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
namespace tt::app::trackballsettings {
extern const ::AppManifest manifest;
constexpr auto* TAG = "TrackballSettings";
// Convert mode to dropdown index (dropdown order: Encoder=0, Pointer=1)
@@ -25,8 +32,29 @@ static uint32_t modeToDropdownIndex(LvglTrackballMode mode) {
return 0; // default to Encoder
}
class TrackballSettingsApp final : public App {
static lv_indev_t* findFirstTrackballIndev() {
lv_indev_t* indev = lv_indev_get_next(nullptr);
while (indev != nullptr) {
void* driver_data = lv_indev_get_driver_data(indev);
if (driver_data) {
LvglDeviceContext* context = static_cast<LvglDeviceContext*>(driver_data);
if (context->device) {
const DeviceType* device_type = device_get_type(context->device);
if (device_type == &TRACKBALL_TYPE) {
return indev;
}
}
}
indev = lv_indev_get_next(indev);
}
return nullptr;
}
namespace {
struct Context {
uint32_t appInstanceId;
LvglTrackballSettings tbSettings;
bool updated = false;
// The trackball indev currently bound by lvgl_devices_attach() at LVGL startup, if any -
@@ -37,218 +65,249 @@ class TrackballSettingsApp final : public App {
lv_obj_t* trackballModeDropdown = nullptr;
lv_obj_t* encoderSensitivitySlider = nullptr;
lv_obj_t* pointerSensitivitySlider = nullptr;
void applyLive() {
if (trackballIndev == nullptr) {
return;
}
lvgl_lock();
lvgl_trackball_set_settings(trackballIndev, &tbSettings);
if (tbSettings.mode == LVGL_TRACKBALL_MODE_POINTER) {
lvgl_trackball_set_cursor_image(trackballIndev, TT_ASSETS_UI_CURSOR);
}
lvgl_unlock();
}
static void onTrackballSwitch(lv_event_t* e) {
auto* app = static_cast<TrackballSettingsApp*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(app->switchTrackball, LV_STATE_CHECKED);
app->tbSettings.enabled = enabled;
app->updated = true;
app->applyLive();
// Enable/disable controls based on trackball state
if (enabled) {
if (app->trackballModeDropdown) lv_obj_clear_state(app->trackballModeDropdown, LV_STATE_DISABLED);
if (app->encoderSensitivitySlider) lv_obj_clear_state(app->encoderSensitivitySlider, LV_STATE_DISABLED);
if (app->pointerSensitivitySlider) lv_obj_clear_state(app->pointerSensitivitySlider, LV_STATE_DISABLED);
} else {
if (app->trackballModeDropdown) lv_obj_add_state(app->trackballModeDropdown, LV_STATE_DISABLED);
if (app->encoderSensitivitySlider) lv_obj_add_state(app->encoderSensitivitySlider, LV_STATE_DISABLED);
if (app->pointerSensitivitySlider) lv_obj_add_state(app->pointerSensitivitySlider, LV_STATE_DISABLED);
}
}
static void onTrackballModeChanged(lv_event_t* e) {
auto* app = static_cast<TrackballSettingsApp*>(lv_event_get_user_data(e));
uint32_t selected = lv_dropdown_get_selected(app->trackballModeDropdown);
// Validate selection matches expected enum values (dropdown order: Encoder=0, Pointer=1)
LvglTrackballMode mode;
switch (selected) {
case 0: mode = LVGL_TRACKBALL_MODE_ENCODER; break;
case 1: mode = LVGL_TRACKBALL_MODE_POINTER; break;
default: return; // Invalid selection, ignore
}
app->tbSettings.mode = mode;
app->updated = true;
// Apply mode change immediately
app->applyLive();
}
static void onEncoderSensitivityChanged(lv_event_t* e) {
auto* app = static_cast<TrackballSettingsApp*>(lv_event_get_user_data(e));
int32_t value = lv_slider_get_value(app->encoderSensitivitySlider);
app->tbSettings.encoder_sensitivity = static_cast<uint8_t>(value);
app->updated = true;
// Apply immediately
app->applyLive();
}
static void onPointerSensitivityChanged(lv_event_t* e) {
auto* app = static_cast<TrackballSettingsApp*>(lv_event_get_user_data(e));
int32_t value = lv_slider_get_value(app->pointerSensitivitySlider);
app->tbSettings.pointer_sensitivity = static_cast<uint8_t>(value);
app->updated = true;
// Apply immediately
app->applyLive();
}
static lv_indev_t* findFirstTrackballIndev() {
lv_indev_t* indev = lv_indev_get_next(nullptr);
while (indev != nullptr) {
void* driver_data = lv_indev_get_driver_data(indev);
if (driver_data) {
LvglDeviceContext* context = static_cast<LvglDeviceContext*>(driver_data);
if (context->device) {
const DeviceType* device_type = device_get_type(context->device);
if (device_type == &TRACKBALL_TYPE) {
return indev;
}
}
}
indev = lv_indev_get_next(indev);
}
return nullptr;
}
public:
void onShow(AppContext& app, lv_obj_t* parent) override {
tbSettings = settings::trackball::loadOrGetDefault();
auto ui_density = lvgl_get_ui_density();
updated = false;
trackballIndev = findFirstTrackballIndev();
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lv_obj_t* toolbar = lvgl::toolbar_create(parent, app);
if (trackballIndev == nullptr) {
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
auto* label = lv_label_create(wrapper);
lv_label_set_text(label, "No trackball device found");
return;
}
// The live indev may still be running with lvgl_trackball_settings_get_default() (it's
// bound at LVGL startup before persisted settings are known) - bring it in line with what
// this screen is about to display.
applyLive();
switchTrackball = lvgl_toolbar_add_switch_action(toolbar);
lv_obj_add_event_cb(switchTrackball, onTrackballSwitch, LV_EVENT_VALUE_CHANGED, this);
if (tbSettings.enabled) lv_obj_add_state(switchTrackball, LV_STATE_CHECKED);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// Trackball mode dropdown
auto* tb_mode_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(tb_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(tb_mode_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(tb_mode_wrapper, 0, LV_STATE_DEFAULT);
auto* tb_mode_label = lv_label_create(tb_mode_wrapper);
lv_label_set_text(tb_mode_label, "Mode");
lv_obj_align(tb_mode_label, LV_ALIGN_LEFT_MID, 0, 0);
trackballModeDropdown = lv_dropdown_create(tb_mode_wrapper);
lv_dropdown_set_options(trackballModeDropdown, "Encoder\nPointer");
lv_obj_align(trackballModeDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_dropdown_set_selected(trackballModeDropdown, modeToDropdownIndex(tbSettings.mode));
lv_obj_add_event_cb(trackballModeDropdown, onTrackballModeChanged, LV_EVENT_VALUE_CHANGED, this);
// Disable dropdown if trackball is disabled
if (!tbSettings.enabled) {
lv_obj_add_state(trackballModeDropdown, LV_STATE_DISABLED);
}
// Encoder sensitivity slider
auto* enc_sens_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(enc_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(enc_sens_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(enc_sens_wrapper, 0, LV_STATE_DEFAULT);
if (ui_density != LVGL_UI_DENSITY_COMPACT) {
lv_obj_set_style_pad_ver(enc_sens_wrapper, 4, LV_STATE_DEFAULT);
}
auto* enc_sens_label = lv_label_create(enc_sens_wrapper);
lv_label_set_text(enc_sens_label, "Encoder Speed");
lv_obj_align(enc_sens_label, LV_ALIGN_LEFT_MID, 0, 0);
encoderSensitivitySlider = lv_slider_create(enc_sens_wrapper);
lv_slider_set_range(encoderSensitivitySlider, 1, 10);
lv_slider_set_value(encoderSensitivitySlider, tbSettings.encoder_sensitivity, LV_ANIM_OFF);
lv_obj_set_width(encoderSensitivitySlider, LV_PCT(50));
lv_obj_align(encoderSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(encoderSensitivitySlider, onEncoderSensitivityChanged, LV_EVENT_VALUE_CHANGED, this);
if (!tbSettings.enabled) {
lv_obj_add_state(encoderSensitivitySlider, LV_STATE_DISABLED);
}
// Pointer sensitivity slider
auto* ptr_sens_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ptr_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(ptr_sens_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ptr_sens_wrapper, 0, LV_STATE_DEFAULT);
if (ui_density != LVGL_UI_DENSITY_COMPACT) {
lv_obj_set_style_pad_ver(ptr_sens_wrapper, 4, LV_STATE_DEFAULT);
}
auto* ptr_sens_label = lv_label_create(ptr_sens_wrapper);
lv_label_set_text(ptr_sens_label, "Pointer Speed");
lv_obj_align(ptr_sens_label, LV_ALIGN_LEFT_MID, 0, 0);
pointerSensitivitySlider = lv_slider_create(ptr_sens_wrapper);
lv_slider_set_range(pointerSensitivitySlider, 1, 10);
lv_slider_set_value(pointerSensitivitySlider, tbSettings.pointer_sensitivity, LV_ANIM_OFF);
lv_obj_set_width(pointerSensitivitySlider, LV_PCT(50));
lv_obj_align(pointerSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(pointerSensitivitySlider, onPointerSensitivityChanged, LV_EVENT_VALUE_CHANGED, this);
if (!tbSettings.enabled) {
lv_obj_add_state(pointerSensitivitySlider, LV_STATE_DISABLED);
}
}
void onHide(AppContext& app) override {
if (updated) {
const auto copy = tbSettings;
getMainDispatcher().dispatch([copy]{ settings::trackball::save(copy); });
updated = false;
}
}
};
extern const AppManifest manifest = {
.appId = "TrackballSettings",
.appName = "Trackball",
.appIcon = LVGL_ICON_SHARED_CIRCLE,
.appCategory = Category::Settings,
.createApp = create<TrackballSettingsApp>
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void applyLive(Context* ctx) {
if (ctx->trackballIndev == nullptr) {
return;
}
lvgl_lock();
lvgl_trackball_set_settings(ctx->trackballIndev, &ctx->tbSettings);
if (ctx->tbSettings.mode == LVGL_TRACKBALL_MODE_POINTER) {
lvgl_trackball_set_cursor_image(ctx->trackballIndev, TT_ASSETS_UI_CURSOR);
}
lvgl_unlock();
}
void onTrackballSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(ctx->switchTrackball, LV_STATE_CHECKED);
ctx->tbSettings.enabled = enabled;
ctx->updated = true;
applyLive(ctx);
// Enable/disable controls based on trackball state
if (enabled) {
if (ctx->trackballModeDropdown) lv_obj_clear_state(ctx->trackballModeDropdown, LV_STATE_DISABLED);
if (ctx->encoderSensitivitySlider) lv_obj_clear_state(ctx->encoderSensitivitySlider, LV_STATE_DISABLED);
if (ctx->pointerSensitivitySlider) lv_obj_clear_state(ctx->pointerSensitivitySlider, LV_STATE_DISABLED);
} else {
if (ctx->trackballModeDropdown) lv_obj_add_state(ctx->trackballModeDropdown, LV_STATE_DISABLED);
if (ctx->encoderSensitivitySlider) lv_obj_add_state(ctx->encoderSensitivitySlider, LV_STATE_DISABLED);
if (ctx->pointerSensitivitySlider) lv_obj_add_state(ctx->pointerSensitivitySlider, LV_STATE_DISABLED);
}
}
void onTrackballModeChanged(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
uint32_t selected = lv_dropdown_get_selected(ctx->trackballModeDropdown);
// Validate selection matches expected enum values (dropdown order: Encoder=0, Pointer=1)
LvglTrackballMode mode;
switch (selected) {
case 0: mode = LVGL_TRACKBALL_MODE_ENCODER; break;
case 1: mode = LVGL_TRACKBALL_MODE_POINTER; break;
default: return; // Invalid selection, ignore
}
ctx->tbSettings.mode = mode;
ctx->updated = true;
// Apply mode change immediately
applyLive(ctx);
}
void onEncoderSensitivityChanged(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
int32_t value = lv_slider_get_value(ctx->encoderSensitivitySlider);
ctx->tbSettings.encoder_sensitivity = static_cast<uint8_t>(value);
ctx->updated = true;
// Apply immediately
applyLive(ctx);
}
void onPointerSensitivityChanged(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
int32_t value = lv_slider_get_value(ctx->pointerSensitivitySlider);
ctx->tbSettings.pointer_sensitivity = static_cast<uint8_t>(value);
ctx->updated = true;
// Apply immediately
applyLive(ctx);
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->tbSettings = settings::trackball::loadOrGetDefault();
auto ui_density = lvgl_get_ui_density();
ctx->updated = false;
ctx->trackballIndev = findFirstTrackballIndev();
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Trackball");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
if (ctx->trackballIndev == nullptr) {
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
auto* label = lv_label_create(wrapper);
lv_label_set_text(label, "No trackball device found");
return;
}
// The live indev may still be running with lvgl_trackball_settings_get_default() (it's
// bound at LVGL startup before persisted settings are known) - bring it in line with what
// this screen is about to display.
applyLive(ctx);
ctx->switchTrackball = lvgl_toolbar_add_switch_action(toolbar);
lv_obj_add_event_cb(ctx->switchTrackball, onTrackballSwitch, LV_EVENT_VALUE_CHANGED, ctx);
if (ctx->tbSettings.enabled) lv_obj_add_state(ctx->switchTrackball, LV_STATE_CHECKED);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// Trackball mode dropdown
auto* tb_mode_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(tb_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(tb_mode_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(tb_mode_wrapper, 0, LV_STATE_DEFAULT);
auto* tb_mode_label = lv_label_create(tb_mode_wrapper);
lv_label_set_text(tb_mode_label, "Mode");
lv_obj_align(tb_mode_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->trackballModeDropdown = lv_dropdown_create(tb_mode_wrapper);
lv_dropdown_set_options(ctx->trackballModeDropdown, "Encoder\nPointer");
lv_obj_align(ctx->trackballModeDropdown, LV_ALIGN_RIGHT_MID, 0, 0);
lv_dropdown_set_selected(ctx->trackballModeDropdown, modeToDropdownIndex(ctx->tbSettings.mode));
lv_obj_add_event_cb(ctx->trackballModeDropdown, onTrackballModeChanged, LV_EVENT_VALUE_CHANGED, ctx);
// Disable dropdown if trackball is disabled
if (!ctx->tbSettings.enabled) {
lv_obj_add_state(ctx->trackballModeDropdown, LV_STATE_DISABLED);
}
// Encoder sensitivity slider
auto* enc_sens_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(enc_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(enc_sens_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(enc_sens_wrapper, 0, LV_STATE_DEFAULT);
if (ui_density != LVGL_UI_DENSITY_COMPACT) {
lv_obj_set_style_pad_ver(enc_sens_wrapper, 4, LV_STATE_DEFAULT);
}
auto* enc_sens_label = lv_label_create(enc_sens_wrapper);
lv_label_set_text(enc_sens_label, "Encoder Speed");
lv_obj_align(enc_sens_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->encoderSensitivitySlider = lv_slider_create(enc_sens_wrapper);
lv_slider_set_range(ctx->encoderSensitivitySlider, 1, 10);
lv_slider_set_value(ctx->encoderSensitivitySlider, ctx->tbSettings.encoder_sensitivity, LV_ANIM_OFF);
lv_obj_set_width(ctx->encoderSensitivitySlider, LV_PCT(50));
lv_obj_align(ctx->encoderSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->encoderSensitivitySlider, onEncoderSensitivityChanged, LV_EVENT_VALUE_CHANGED, ctx);
if (!ctx->tbSettings.enabled) {
lv_obj_add_state(ctx->encoderSensitivitySlider, LV_STATE_DISABLED);
}
// Pointer sensitivity slider
auto* ptr_sens_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ptr_sens_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_hor(ptr_sens_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ptr_sens_wrapper, 0, LV_STATE_DEFAULT);
if (ui_density != LVGL_UI_DENSITY_COMPACT) {
lv_obj_set_style_pad_ver(ptr_sens_wrapper, 4, LV_STATE_DEFAULT);
}
auto* ptr_sens_label = lv_label_create(ptr_sens_wrapper);
lv_label_set_text(ptr_sens_label, "Pointer Speed");
lv_obj_align(ptr_sens_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->pointerSensitivitySlider = lv_slider_create(ptr_sens_wrapper);
lv_slider_set_range(ctx->pointerSensitivitySlider, 1, 10);
lv_slider_set_value(ctx->pointerSensitivitySlider, ctx->tbSettings.pointer_sensitivity, LV_ANIM_OFF);
lv_obj_set_width(ctx->pointerSensitivitySlider, LV_PCT(50));
lv_obj_align(ctx->pointerSensitivitySlider, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->pointerSensitivitySlider, onPointerSensitivityChanged, LV_EVENT_VALUE_CHANGED, ctx);
if (!ctx->tbSettings.enabled) {
lv_obj_add_state(ctx->pointerSensitivitySlider, LV_STATE_DISABLED);
}
}
// Mirrors the old onHide() behaviour: persist the settings (regardless of whether the app is
// giving up its thread for a save/resume cycle, or closing for good) whenever they changed.
void persistIfUpdated(Context& ctx) {
if (ctx.updated) {
const auto copy = ctx.tbSettings;
getMainDispatcher().dispatch([copy]{ settings::trackball::save(copy); });
ctx.updated = false;
}
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
persistIfUpdated(ctx);
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "TrackballSettings",
.name = "Trackball",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
}
@@ -1,70 +1,125 @@
#include <Tactility/app/App.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/hal/usb/Usb.h>
#include <Tactility/lvgl/Toolbar.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl.h>
#include <lvgl/icons/shared.h>
#include <lvgl/widgets/toolbar.h>
#define TAG "usb_settings"
namespace tt::app::usbsettings {
static void onRebootMassStorageSdmmc(lv_event_t* event) {
extern const ::AppManifest manifest;
namespace {
struct Context {
uint32_t appInstanceId;
};
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onRebootMassStorageSdmmc(lv_event_t* event) {
hal::usb::rebootIntoMassStorageSdmmc();
}
// Flash reboot handler
static void onRebootMassStorageFlash(lv_event_t* event) {
void onRebootMassStorageFlash(lv_event_t* event) {
hal::usb::rebootIntoMassStorageFlash();
}
class UsbSettingsApp : public App {
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
void onShow(AppContext& app, lv_obj_t* parent) override {
auto* toolbar = lvgl::toolbar_create(parent, app);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
auto* toolbar = lvgl_toolbar_create(parent, "USB");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0);
// Create a wrapper container for buttons
auto* wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_size(wrapper, lv_pct(100), LV_SIZE_CONTENT);
lv_obj_align(wrapper, LV_ALIGN_CENTER, 0, 0);
// Create a wrapper container for buttons
auto* wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(wrapper, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_size(wrapper, lv_pct(100), LV_SIZE_CONTENT);
lv_obj_align(wrapper, LV_ALIGN_CENTER, 0, 0);
bool hasSd = hal::usb::canRebootIntoMassStorageSdmmc();
bool hasFlash = hal::usb::canRebootIntoMassStorageFlash();
bool hasSd = hal::usb::canRebootIntoMassStorageSdmmc();
bool hasFlash = hal::usb::canRebootIntoMassStorageFlash();
if (hasSd) {
auto* button_sd = lv_button_create(wrapper);
auto* label_sd = lv_label_create(button_sd);
lv_label_set_text(label_sd, "Reboot as USB storage (SD)");
lv_obj_add_event_cb(button_sd, onRebootMassStorageSdmmc, LV_EVENT_SHORT_CLICKED, nullptr);
if (hasSd) {
auto* button_sd = lv_button_create(wrapper);
auto* label_sd = lv_label_create(button_sd);
lv_label_set_text(label_sd, "Reboot as USB storage (SD)");
lv_obj_add_event_cb(button_sd, onRebootMassStorageSdmmc, LV_EVENT_SHORT_CLICKED, nullptr);
}
if (hasFlash) {
auto* button_flash = lv_button_create(wrapper);
auto* label_flash = lv_label_create(button_flash);
lv_label_set_text(label_flash, "Reboot as USB storage (Flash)");
lv_obj_add_event_cb(button_flash, onRebootMassStorageFlash, LV_EVENT_SHORT_CLICKED, nullptr);
}
if (!hasSd && !hasFlash) {
bool supported = hal::usb::isSupported();
const char* message = supported ? "USB storage not available" : "USB driver not supported";
auto* label = lv_label_create(wrapper);
lv_label_set_text(label, message);
}
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
if (hasFlash) {
auto* button_flash = lv_button_create(wrapper);
auto* label_flash = lv_label_create(button_flash);
lv_label_set_text(label_flash, "Reboot as USB storage (Flash)");
lv_obj_add_event_cb(button_flash, onRebootMassStorageFlash, LV_EVENT_SHORT_CLICKED, nullptr);
}
if (!hasSd && !hasFlash) {
bool supported = hal::usb::isSupported();
const char* message = supported ? "USB storage not available" : "USB driver not supported";
auto* label = lv_label_create(wrapper);
lv_label_set_text(label, message);
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
};
extern const AppManifest manifest = {
.appId = "UsbSettings",
.appName = "USB",
.appIcon = LVGL_ICON_SHARED_USB,
.appCategory = Category::Settings,
.createApp = create<UsbSettingsApp>
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
extern const ::AppManifest manifest = {
.id = "UsbSettings",
.name = "USB",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace
@@ -2,14 +2,20 @@
#include <Tactility/Tactility.h>
#include <Tactility/settings/WebServerSettings.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/service/webserver/WebServerService.h>
#include <Tactility/service/wifi/Wifi.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
#include <lvgl/icons/shared.h>
#include <lvgl.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <esp_netif.h>
#include <esp_wifi.h>
@@ -18,7 +24,12 @@ namespace tt::app::webserversettings {
constexpr auto* TAG = "WebServerSettingsApp";
class WebServerSettingsApp final : public App {
extern const ::AppManifest manifest;
namespace {
struct Context {
uint32_t appInstanceId;
settings::webserver::WebServerSettings wsSettings;
settings::webserver::WebServerSettings originalSettings;
@@ -33,347 +44,397 @@ class WebServerSettingsApp final : public App {
lv_obj_t* textAreaWebServerPassword = nullptr;
lv_obj_t* labelUrl = nullptr;
lv_obj_t* labelUrlValue = nullptr;
};
static void onWifiModeChanged(lv_event_t* e) {
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e));
auto index = lv_dropdown_get_selected(dropdown);
getMainDispatcher().dispatch([app, index] {
app->wsSettings.wifiMode = static_cast<settings::webserver::WiFiMode>(index);
app->updated = true;
app->wifiSettingsChanged = true;
lvgl_lock();
app->updateUrlDisplay();
lvgl_unlock();
});
void updateUrlDisplay(Context* ctx);
void createWidgets(lv_obj_t* parent, void* userData);
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onWifiModeChanged(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
auto* dropdown = static_cast<lv_obj_t*>(lv_event_get_target(e));
auto index = lv_dropdown_get_selected(dropdown);
getMainDispatcher().dispatch([ctx, index] {
ctx->wsSettings.wifiMode = static_cast<settings::webserver::WiFiMode>(index);
ctx->updated = true;
ctx->wifiSettingsChanged = true;
lvgl_lock();
updateUrlDisplay(ctx);
lvgl_unlock();
});
}
void onWebServerEnabledSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED);
getMainDispatcher().dispatch([ctx, enabled] {
ctx->wsSettings.webServerEnabled = enabled;
ctx->updated = true;
lvgl_lock();
updateUrlDisplay(ctx);
lvgl_unlock();
// Apply immediately instead of waiting for app exit
const auto copy = ctx->wsSettings;
if (!settings::webserver::save(copy)) {
LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot");
}
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
LOG_I(TAG, "WebServer %s", enabled ? "enabling..." : "disabling...");
service::webserver::setWebServerEnabled(enabled);
});
}
void onWebServerAuthEnabledSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED);
if (ctx->textAreaWebServerUsername && ctx->textAreaWebServerPassword) {
if (enabled) {
lv_obj_remove_state(ctx->textAreaWebServerUsername, LV_STATE_DISABLED);
lv_obj_add_flag(ctx->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
lv_obj_remove_state(ctx->textAreaWebServerPassword, LV_STATE_DISABLED);
lv_obj_add_flag(ctx->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
} else {
lv_obj_add_state(ctx->textAreaWebServerUsername, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_state(ctx->textAreaWebServerPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
}
}
static void onWebServerEnabledSwitch(lv_event_t* e) {
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(app->switchWebServerEnabled, LV_STATE_CHECKED);
getMainDispatcher().dispatch([app, enabled] {
app->wsSettings.webServerEnabled = enabled;
app->updated = true;
lvgl_lock();
app->updateUrlDisplay();
lvgl_unlock();
getMainDispatcher().dispatch([ctx, enabled] {
ctx->wsSettings.webServerAuthEnabled = enabled;
ctx->updated = true;
});
}
// Apply immediately instead of waiting for app exit
const auto copy = app->wsSettings;
void onCredentialChanged(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
getMainDispatcher().dispatch([ctx] {
ctx->updated = true;
});
}
void onApPasswordChanged(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
getMainDispatcher().dispatch([ctx] {
ctx->updated = true;
ctx->wifiSettingsChanged = true;
});
}
void onApOpenNetworkSwitch(lv_event_t* e) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(e));
bool openNetwork = lv_obj_has_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED);
if (ctx->textAreaApPassword) {
if (openNetwork) {
lv_obj_add_state(ctx->textAreaApPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
} else {
lv_obj_remove_state(ctx->textAreaApPassword, LV_STATE_DISABLED);
lv_obj_add_flag(ctx->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
}
}
getMainDispatcher().dispatch([ctx, openNetwork] {
ctx->wsSettings.apOpenNetwork = openNetwork;
ctx->updated = true;
ctx->wifiSettingsChanged = true;
});
}
void updateUrlDisplay(Context* ctx) {
if (!ctx->labelUrlValue) return;
if (!ctx->wsSettings.webServerEnabled) {
lv_label_set_text(ctx->labelUrlValue, "Disabled");
return;
}
std::string url = "http://";
if (ctx->wsSettings.wifiMode == settings::webserver::WiFiMode::AccessPoint) {
// AP mode - always 192.168.4.1
url += "192.168.4.1";
} else {
// Station mode - try to get actual IP
esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (netif != nullptr) {
esp_netif_ip_info_t ip_info;
if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0) {
char ip_str[16];
snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip));
url += ip_str;
} else {
url = "Connecting...";
}
} else {
url = "Not connected";
}
}
if (url.starts_with("http://")) {
if (ctx->wsSettings.webServerPort != 80) {
url += ":" + std::to_string(ctx->wsSettings.webServerPort);
}
}
lv_label_set_text(ctx->labelUrlValue, url.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);
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Web Server");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
// Web Server Enable toggle
ctx->switchWebServerEnabled = lvgl_toolbar_add_switch_action(toolbar);
if (ctx->wsSettings.webServerEnabled) {
lv_obj_add_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED);
}
lv_obj_add_event_cb(ctx->switchWebServerEnabled, onWebServerEnabledSwitch, LV_EVENT_VALUE_CHANGED, ctx);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// WiFi Mode dropdown
auto* wifi_mode_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(wifi_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(wifi_mode_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(wifi_mode_wrapper, 0, LV_STATE_DEFAULT);
auto* wifi_mode_label = lv_label_create(wifi_mode_wrapper);
lv_label_set_text(wifi_mode_label, "WiFi Mode");
lv_obj_align(wifi_mode_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->dropdownWifiMode = lv_dropdown_create(wifi_mode_wrapper);
lv_obj_align(ctx->dropdownWifiMode, LV_ALIGN_RIGHT_MID, 0, 0);
lv_dropdown_set_options(ctx->dropdownWifiMode, "Station\nAccess Point");
lv_dropdown_set_selected(ctx->dropdownWifiMode, static_cast<uint32_t>(ctx->wsSettings.wifiMode));
lv_obj_add_event_cb(ctx->dropdownWifiMode, onWifiModeChanged, LV_EVENT_VALUE_CHANGED, ctx);
// AP Open Network toggle
auto* ap_open_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ap_open_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ap_open_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ap_open_wrapper, 0, LV_STATE_DEFAULT);
auto* ap_open_label = lv_label_create(ap_open_wrapper);
lv_label_set_text(ap_open_label, "AP Open Network");
lv_obj_align(ap_open_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->switchApOpenNetwork = lv_switch_create(ap_open_wrapper);
if (ctx->wsSettings.apOpenNetwork) lv_obj_add_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED);
lv_obj_align(ctx->switchApOpenNetwork, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->switchApOpenNetwork, onApOpenNetworkSwitch, LV_EVENT_VALUE_CHANGED, ctx);
// AP Password
auto* ap_pass_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ap_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ap_pass_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ap_pass_wrapper, 0, LV_STATE_DEFAULT);
auto* ap_pass_label = lv_label_create(ap_pass_wrapper);
lv_label_set_text(ap_pass_label, "AP Password");
lv_obj_align(ap_pass_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->textAreaApPassword = lv_textarea_create(ap_pass_wrapper);
lv_obj_set_width(ctx->textAreaApPassword, 120);
lv_obj_align(ctx->textAreaApPassword, LV_ALIGN_RIGHT_MID, 0, 0);
lv_textarea_set_one_line(ctx->textAreaApPassword, true);
lv_textarea_set_max_length(ctx->textAreaApPassword, 64);
lv_textarea_set_password_mode(ctx->textAreaApPassword, true);
lv_textarea_set_text(ctx->textAreaApPassword, ctx->wsSettings.apPassword.c_str());
lv_obj_add_event_cb(ctx->textAreaApPassword, onApPasswordChanged, LV_EVENT_VALUE_CHANGED, ctx);
// Disable password field if open network is enabled
if (ctx->wsSettings.apOpenNetwork) {
lv_obj_add_state(ctx->textAreaApPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
}
// Web Server Authentication Enable toggle
auto* ws_auth_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ws_auth_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ws_auth_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ws_auth_wrapper, 0, LV_STATE_DEFAULT);
auto* ws_auth_label = lv_label_create(ws_auth_wrapper);
lv_label_set_text(ws_auth_label, "Require Authentication");
lv_obj_align(ws_auth_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->switchWebServerAuthEnabled = lv_switch_create(ws_auth_wrapper);
if (ctx->wsSettings.webServerAuthEnabled) lv_obj_add_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED);
lv_obj_align(ctx->switchWebServerAuthEnabled, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->switchWebServerAuthEnabled, onWebServerAuthEnabledSwitch, LV_EVENT_VALUE_CHANGED, ctx);
// WebServer Username
auto* ws_user_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ws_user_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ws_user_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ws_user_wrapper, 0, LV_STATE_DEFAULT);
auto* ws_user_label = lv_label_create(ws_user_wrapper);
lv_label_set_text(ws_user_label, "Username");
lv_obj_align(ws_user_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->textAreaWebServerUsername = lv_textarea_create(ws_user_wrapper);
if (!ctx->wsSettings.webServerAuthEnabled) {
lv_obj_add_state(ctx->textAreaWebServerUsername, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
}
lv_obj_set_width(ctx->textAreaWebServerUsername, 120);
lv_obj_align(ctx->textAreaWebServerUsername, LV_ALIGN_RIGHT_MID, 0, 0);
lv_textarea_set_one_line(ctx->textAreaWebServerUsername, true);
lv_textarea_set_max_length(ctx->textAreaWebServerUsername, 32);
lv_textarea_set_text(ctx->textAreaWebServerUsername, ctx->wsSettings.webServerUsername.c_str());
lv_obj_add_event_cb(ctx->textAreaWebServerUsername, onCredentialChanged, LV_EVENT_VALUE_CHANGED, ctx);
// WebServer Password
auto* ws_pass_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ws_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ws_pass_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ws_pass_wrapper, 0, LV_STATE_DEFAULT);
auto* ws_pass_label = lv_label_create(ws_pass_wrapper);
lv_label_set_text(ws_pass_label, "Password");
lv_obj_align(ws_pass_label, LV_ALIGN_LEFT_MID, 0, 0);
ctx->textAreaWebServerPassword = lv_textarea_create(ws_pass_wrapper);
if (!ctx->wsSettings.webServerAuthEnabled) {
lv_obj_add_state(ctx->textAreaWebServerPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(ctx->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
}
lv_obj_set_width(ctx->textAreaWebServerPassword, 120);
lv_obj_align(ctx->textAreaWebServerPassword, LV_ALIGN_RIGHT_MID, 0, 0);
lv_textarea_set_one_line(ctx->textAreaWebServerPassword, true);
lv_textarea_set_max_length(ctx->textAreaWebServerPassword, 64);
lv_textarea_set_password_mode(ctx->textAreaWebServerPassword, true);
lv_textarea_set_text(ctx->textAreaWebServerPassword, ctx->wsSettings.webServerPassword.c_str());
lv_obj_add_event_cb(ctx->textAreaWebServerPassword, onCredentialChanged, LV_EVENT_VALUE_CHANGED, ctx);
// URL Display
auto* url_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(url_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(url_wrapper, 10, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(url_wrapper, 1, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(url_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_flex_cross_place(url_wrapper, LV_FLEX_ALIGN_START, 0);
ctx->labelUrl = lv_label_create(url_wrapper);
lv_label_set_text(ctx->labelUrl, "Web Server URL:");
ctx->labelUrlValue = lv_label_create(url_wrapper);
if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) {
lv_obj_set_style_text_color(ctx->labelUrlValue, lv_theme_get_color_secondary(ctx->labelUrlValue), LV_PART_MAIN);
} else {
lv_obj_set_style_text_color(ctx->labelUrlValue, lv_palette_main(LV_PALETTE_BLUE), 0);
}
updateUrlDisplay(ctx);
// Info text
auto* info_label = lv_label_create(main_wrapper);
lv_label_set_long_mode(info_label, LV_LABEL_LONG_WRAP);
lv_obj_set_width(info_label, LV_PCT(95));
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
lv_obj_set_style_text_color(info_label, lv_palette_main(LV_PALETTE_GREY), 0);
}
lv_label_set_text(info_label,
"WiFi Station credentials are managed separately.\n"
"Use the WiFi menu to connect to networks.\n\n"
"AP mode uses the password configured above.");
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.wsSettings = settings::webserver::loadOrGetDefault();
// Reflect the server's actual running state, in case it differs from the persisted setting
ctx.wsSettings.webServerEnabled = service::webserver::isWebServerEnabled();
ctx.originalSettings = ctx.wsSettings;
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
// Equivalent of the old model's onHide().
if (ctx.updated) {
// Read values from text areas - the window (and its widgets) is still alive at this
// point, since window_manager_remove() below hasn't run yet, but this runs on this
// app's own thread rather than the LVGL task, so the LVGL lock is needed.
lvgl_lock();
if (ctx.textAreaApPassword) {
ctx.wsSettings.apPassword = lv_textarea_get_text(ctx.textAreaApPassword);
}
if (ctx.textAreaWebServerUsername) {
ctx.wsSettings.webServerUsername = lv_textarea_get_text(ctx.textAreaWebServerUsername);
}
if (ctx.textAreaWebServerPassword) {
ctx.wsSettings.webServerPassword = lv_textarea_get_text(ctx.textAreaWebServerPassword);
}
lvgl_unlock();
// Save to flash only (settings sync at boot handles SD restore)
// Note: the enable/disable toggle already saved and applied itself immediately
const auto copy = ctx.wsSettings;
const bool wifiChanged = ctx.wifiSettingsChanged;
getMainDispatcher().dispatch([copy, wifiChanged] {
// Save to flash (fast, low memory pressure)
if (!settings::webserver::save(copy)) {
LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot");
}
// Publish event immediately after save so WebServer cache refreshes BEFORE requests arrive
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
LOG_I(TAG, "WebServer %s", enabled ? "enabling..." : "disabling...");
service::webserver::setWebServerEnabled(enabled);
// Only reconnect WiFi if WiFi settings actually changed
if (wifiChanged) {
LOG_I(TAG, "WiFi mode changed to %s", copy.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station");
}
});
}
static void onWebServerAuthEnabledSwitch(lv_event_t* e) {
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
bool enabled = lv_obj_has_state(app->switchWebServerAuthEnabled, LV_STATE_CHECKED);
window_manager_remove(window);
app_event_unsubscribe(&sub);
if (app->textAreaWebServerUsername && app->textAreaWebServerPassword) {
if (enabled) {
lv_obj_remove_state(app->textAreaWebServerUsername, LV_STATE_DISABLED);
lv_obj_add_flag(app->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
return 0;
}
lv_obj_remove_state(app->textAreaWebServerPassword, LV_STATE_DISABLED);
lv_obj_add_flag(app->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
} else {
lv_obj_add_state(app->textAreaWebServerUsername, LV_STATE_DISABLED);
lv_obj_remove_flag(app->textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
} // namespace
lv_obj_add_state(app->textAreaWebServerPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(app->textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
}
}
getMainDispatcher().dispatch([app, enabled] {
app->wsSettings.webServerAuthEnabled = enabled;
app->updated = true;
});
}
static void onCredentialChanged(lv_event_t* e) {
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
getMainDispatcher().dispatch([app] {
app->updated = true;
});
}
static void onApPasswordChanged(lv_event_t* e) {
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
getMainDispatcher().dispatch([app] {
app->updated = true;
app->wifiSettingsChanged = true;
});
}
static void onApOpenNetworkSwitch(lv_event_t* e) {
auto* app = static_cast<WebServerSettingsApp*>(lv_event_get_user_data(e));
bool openNetwork = lv_obj_has_state(app->switchApOpenNetwork, LV_STATE_CHECKED);
if (app->textAreaApPassword) {
if (openNetwork) {
lv_obj_add_state(app->textAreaApPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(app->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
} else {
lv_obj_remove_state(app->textAreaApPassword, LV_STATE_DISABLED);
lv_obj_add_flag(app->textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
}
}
getMainDispatcher().dispatch([app, openNetwork] {
app->wsSettings.apOpenNetwork = openNetwork;
app->updated = true;
app->wifiSettingsChanged = true;
});
}
void updateUrlDisplay() {
if (!labelUrlValue) return;
if (!wsSettings.webServerEnabled) {
lv_label_set_text(labelUrlValue, "Disabled");
return;
}
std::string url = "http://";
if (wsSettings.wifiMode == settings::webserver::WiFiMode::AccessPoint) {
// AP mode - always 192.168.4.1
url += "192.168.4.1";
} else {
// Station mode - try to get actual IP
esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (netif != nullptr) {
esp_netif_ip_info_t ip_info;
if (esp_netif_get_ip_info(netif, &ip_info) == ESP_OK && ip_info.ip.addr != 0) {
char ip_str[16];
snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip));
url += ip_str;
} else {
url = "Connecting...";
}
} else {
url = "Not connected";
}
}
if (url.starts_with("http://")) {
if (wsSettings.webServerPort != 80) {
url += ":" + std::to_string(wsSettings.webServerPort);
}
}
lv_label_set_text(labelUrlValue, url.c_str());
}
public:
void onCreate(AppContext& app) override {
wsSettings = settings::webserver::loadOrGetDefault();
// Reflect the server's actual running state, in case it differs from the persisted setting
wsSettings.webServerEnabled = service::webserver::isWebServerEnabled();
originalSettings = wsSettings;
}
void onShow(AppContext& app, lv_obj_t* parent) override {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lv_obj_t* toolbar = lvgl::toolbar_create(parent, app);
// Web Server Enable toggle
switchWebServerEnabled = lvgl_toolbar_add_switch_action(toolbar);
if (wsSettings.webServerEnabled) {
lv_obj_add_state(switchWebServerEnabled, LV_STATE_CHECKED);
}
lv_obj_add_event_cb(switchWebServerEnabled, onWebServerEnabledSwitch, LV_EVENT_VALUE_CHANGED, this);
auto* main_wrapper = lv_obj_create(parent);
lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_width(main_wrapper, LV_PCT(100));
lv_obj_set_flex_grow(main_wrapper, 1);
// WiFi Mode dropdown
auto* wifi_mode_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(wifi_mode_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(wifi_mode_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(wifi_mode_wrapper, 0, LV_STATE_DEFAULT);
auto* wifi_mode_label = lv_label_create(wifi_mode_wrapper);
lv_label_set_text(wifi_mode_label, "WiFi Mode");
lv_obj_align(wifi_mode_label, LV_ALIGN_LEFT_MID, 0, 0);
dropdownWifiMode = lv_dropdown_create(wifi_mode_wrapper);
lv_obj_align(dropdownWifiMode, LV_ALIGN_RIGHT_MID, 0, 0);
lv_dropdown_set_options(dropdownWifiMode, "Station\nAccess Point");
lv_dropdown_set_selected(dropdownWifiMode, static_cast<uint32_t>(wsSettings.wifiMode));
lv_obj_add_event_cb(dropdownWifiMode, onWifiModeChanged, LV_EVENT_VALUE_CHANGED, this);
// AP Open Network toggle
auto* ap_open_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ap_open_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ap_open_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ap_open_wrapper, 0, LV_STATE_DEFAULT);
auto* ap_open_label = lv_label_create(ap_open_wrapper);
lv_label_set_text(ap_open_label, "AP Open Network");
lv_obj_align(ap_open_label, LV_ALIGN_LEFT_MID, 0, 0);
switchApOpenNetwork = lv_switch_create(ap_open_wrapper);
if (wsSettings.apOpenNetwork) lv_obj_add_state(switchApOpenNetwork, LV_STATE_CHECKED);
lv_obj_align(switchApOpenNetwork, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(switchApOpenNetwork, onApOpenNetworkSwitch, LV_EVENT_VALUE_CHANGED, this);
// AP Password
auto* ap_pass_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ap_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ap_pass_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ap_pass_wrapper, 0, LV_STATE_DEFAULT);
auto* ap_pass_label = lv_label_create(ap_pass_wrapper);
lv_label_set_text(ap_pass_label, "AP Password");
lv_obj_align(ap_pass_label, LV_ALIGN_LEFT_MID, 0, 0);
textAreaApPassword = lv_textarea_create(ap_pass_wrapper);
lv_obj_set_width(textAreaApPassword, 120);
lv_obj_align(textAreaApPassword, LV_ALIGN_RIGHT_MID, 0, 0);
lv_textarea_set_one_line(textAreaApPassword, true);
lv_textarea_set_max_length(textAreaApPassword, 64);
lv_textarea_set_password_mode(textAreaApPassword, true);
lv_textarea_set_text(textAreaApPassword, wsSettings.apPassword.c_str());
lv_obj_add_event_cb(textAreaApPassword, onApPasswordChanged, LV_EVENT_VALUE_CHANGED, this);
// Disable password field if open network is enabled
if (wsSettings.apOpenNetwork) {
lv_obj_add_state(textAreaApPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(textAreaApPassword, LV_OBJ_FLAG_CLICKABLE);
}
// Web Server Authentication Enable toggle
auto* ws_auth_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ws_auth_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ws_auth_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ws_auth_wrapper, 0, LV_STATE_DEFAULT);
auto* ws_auth_label = lv_label_create(ws_auth_wrapper);
lv_label_set_text(ws_auth_label, "Require Authentication");
lv_obj_align(ws_auth_label, LV_ALIGN_LEFT_MID, 0, 0);
switchWebServerAuthEnabled = lv_switch_create(ws_auth_wrapper);
if (wsSettings.webServerAuthEnabled) lv_obj_add_state(switchWebServerAuthEnabled, LV_STATE_CHECKED);
lv_obj_align(switchWebServerAuthEnabled, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(switchWebServerAuthEnabled, onWebServerAuthEnabledSwitch, LV_EVENT_VALUE_CHANGED, this);
// WebServer Username
auto* ws_user_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ws_user_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ws_user_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ws_user_wrapper, 0, LV_STATE_DEFAULT);
auto* ws_user_label = lv_label_create(ws_user_wrapper);
lv_label_set_text(ws_user_label, "Username");
lv_obj_align(ws_user_label, LV_ALIGN_LEFT_MID, 0, 0);
textAreaWebServerUsername = lv_textarea_create(ws_user_wrapper);
if (!wsSettings.webServerAuthEnabled) {
lv_obj_add_state(textAreaWebServerUsername, LV_STATE_DISABLED);
lv_obj_remove_flag(textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE);
}
lv_obj_set_width(textAreaWebServerUsername, 120);
lv_obj_align(textAreaWebServerUsername, LV_ALIGN_RIGHT_MID, 0, 0);
lv_textarea_set_one_line(textAreaWebServerUsername, true);
lv_textarea_set_max_length(textAreaWebServerUsername, 32);
lv_textarea_set_text(textAreaWebServerUsername, wsSettings.webServerUsername.c_str());
lv_obj_add_event_cb(textAreaWebServerUsername, onCredentialChanged, LV_EVENT_VALUE_CHANGED, this);
// WebServer Password
auto* ws_pass_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(ws_pass_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ws_pass_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ws_pass_wrapper, 0, LV_STATE_DEFAULT);
auto* ws_pass_label = lv_label_create(ws_pass_wrapper);
lv_label_set_text(ws_pass_label, "Password");
lv_obj_align(ws_pass_label, LV_ALIGN_LEFT_MID, 0, 0);
textAreaWebServerPassword = lv_textarea_create(ws_pass_wrapper);
if (!wsSettings.webServerAuthEnabled) {
lv_obj_add_state(textAreaWebServerPassword, LV_STATE_DISABLED);
lv_obj_remove_flag(textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE);
}
lv_obj_set_width(textAreaWebServerPassword, 120);
lv_obj_align(textAreaWebServerPassword, LV_ALIGN_RIGHT_MID, 0, 0);
lv_textarea_set_one_line(textAreaWebServerPassword, true);
lv_textarea_set_max_length(textAreaWebServerPassword, 64);
lv_textarea_set_password_mode(textAreaWebServerPassword, true);
lv_textarea_set_text(textAreaWebServerPassword, wsSettings.webServerPassword.c_str());
lv_obj_add_event_cb(textAreaWebServerPassword, onCredentialChanged, LV_EVENT_VALUE_CHANGED, this);
// URL Display
auto* url_wrapper = lv_obj_create(main_wrapper);
lv_obj_set_size(url_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(url_wrapper, 10, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(url_wrapper, 1, LV_STATE_DEFAULT);
lv_obj_set_flex_flow(url_wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_flex_cross_place(url_wrapper, LV_FLEX_ALIGN_START, 0);
labelUrl = lv_label_create(url_wrapper);
lv_label_set_text(labelUrl, "Web Server URL:");
labelUrlValue = lv_label_create(url_wrapper);
if (lv_display_get_color_format(lv_obj_get_display(parent)) == LV_COLOR_FORMAT_L8) {
lv_obj_set_style_text_color(labelUrlValue, lv_theme_get_color_secondary(labelUrlValue), LV_PART_MAIN);
} else {
lv_obj_set_style_text_color(labelUrlValue, lv_palette_main(LV_PALETTE_BLUE), 0);
}
updateUrlDisplay();
// Info text
auto* info_label = lv_label_create(main_wrapper);
lv_label_set_long_mode(info_label, LV_LABEL_LONG_WRAP);
lv_obj_set_width(info_label, LV_PCT(95));
if (lv_display_get_color_format(lv_obj_get_display(parent)) != LV_COLOR_FORMAT_L8) {
lv_obj_set_style_text_color(info_label, lv_palette_main(LV_PALETTE_GREY), 0);
}
lv_label_set_text(info_label,
"WiFi Station credentials are managed separately.\n"
"Use the WiFi menu to connect to networks.\n\n"
"AP mode uses the password configured above.");
}
void onHide(AppContext& app) override {
if (updated) {
// Read values from text areas
if (textAreaApPassword) {
wsSettings.apPassword = lv_textarea_get_text(textAreaApPassword);
}
if (textAreaWebServerUsername) {
wsSettings.webServerUsername = lv_textarea_get_text(textAreaWebServerUsername);
}
if (textAreaWebServerPassword) {
wsSettings.webServerPassword = lv_textarea_get_text(textAreaWebServerPassword);
}
// Save to flash only (settings sync at boot handles SD restore)
// Note: the enable/disable toggle already saved and applied itself immediately
const auto copy = wsSettings;
const bool wifiChanged = wifiSettingsChanged;
getMainDispatcher().dispatch([copy, wifiChanged]{
// Save to flash (fast, low memory pressure)
if (!settings::webserver::save(copy)) {
LOG_W(TAG, "Failed to persist WebServer settings; changes may be lost on reboot");
}
// Publish event immediately after save so WebServer cache refreshes BEFORE requests arrive
service::webserver::getPubsub()->publish(service::webserver::WebServerEvent::WebServerSettingsChanged);
// Only reconnect WiFi if WiFi settings actually changed
if (wifiChanged) {
LOG_I(TAG, "WiFi mode changed to %s", copy.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station");
}
});
}
}
};
extern const AppManifest manifest = {
.appId = "WebServerSettings",
.appName = "Web Server",
.appIcon = LVGL_ICON_SHARED_CLOUD,
.appCategory = Category::System,
.createApp = create<WebServerSettingsApp>
extern const ::AppManifest manifest = {
.id = "WebServerSettings",
.name = "Web Server",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
}
@@ -1,250 +1,266 @@
#include <Tactility/app/App.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/alertdialog/AlertDialog.h>
#include <Tactility/lvgl/Style.h>
#include <Tactility/service/wifi/Wifi.h>
#include <Tactility/service/wifi/WifiApSettings.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/check.h>
#include <tactility/log.h>
namespace tt::app::wifiapsettings {
constexpr auto* TAG = "WifiApSettings";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
void start(const std::string& ssid) {
auto bundle = std::make_shared<Bundle>();
bundle->putString("ssid", ssid);
app::start(manifest.appId, bundle);
}
namespace {
class WifiApSettings : public App {
struct Context {
uint32_t appInstanceId;
std::string ssid;
bool viewEnabled = false;
lv_obj_t* busySpinner = nullptr;
lv_obj_t* connectButton = nullptr;
lv_obj_t* disconnectButton = nullptr;
std::string ssid;
uint32_t forgetDialogId = 0;
PubSub<service::wifi::WifiEvent>::SubscriptionHandle wifiSubscription = nullptr;
static void onPressForget(lv_event_t* event) {
std::vector<std::string> choices = {
"Yes",
"No"
};
alertdialog::start("Confirmation", "Forget the Wi-Fi access point?", choices);
}
static void onToggleAutoConnect(lv_event_t* event) {
auto* self = static_cast<WifiApSettings*>(lv_event_get_user_data(event));
auto* enable_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
service::wifi::settings::WifiApSettings settings;
if (service::wifi::settings::load(self->ssid.c_str(), settings)) {
settings.autoConnect = is_on;
if (!service::wifi::settings::save(settings)) {
LOG_E(TAG, "Failed to save settings");
}
} else {
LOG_E(TAG, "Failed to load settings");
}
}
static void onPressConnect(lv_event_t* event) {
auto app = getCurrentAppContext();
auto parameters = app->getParameters();
check(parameters != nullptr, "Parameters missing");
std::string ssid = parameters->getString("ssid");
service::wifi::settings::WifiApSettings settings;
if (service::wifi::settings::load(ssid.c_str(), settings)) {
auto* button = lv_event_get_target_obj(event);
lv_obj_add_state(button, LV_STATE_DISABLED);
service::wifi::connect(settings, false);
}
}
static void onPressDisconnect(lv_event_t* event) {
if (service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive) {
auto* button = lv_event_get_target_obj(event);
lv_obj_add_state(button, LV_STATE_DISABLED);
service::wifi::disconnect();
}
}
void onWifiEvent(service::wifi::WifiEvent event) const {
requestViewUpdate();
}
void requestViewUpdate() const {
if (viewEnabled) {
lvgl_lock();
updateViews();
lvgl_unlock();
}
}
void updateConnectButton() const {
if (service::wifi::getConnectionTarget() == ssid && service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive) {
lv_obj_remove_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(connectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(disconnectButton, LV_STATE_DISABLED);
} else {
lv_obj_add_flag(disconnectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(connectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(connectButton, LV_STATE_DISABLED);
}
}
void updateBusySpinner() const {
if (service::wifi::getRadioState() == service::wifi::RadioState::ConnectionPending) {
lv_obj_remove_flag(busySpinner, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(busySpinner, LV_OBJ_FLAG_HIDDEN);
}
}
void updateViews() const {
updateConnectButton();
updateBusySpinner();
}
public:
void onCreate(AppContext& app) override {
const auto parameters = app.getParameters();
check(parameters != nullptr, "Parameters missing");
ssid = parameters->getString("ssid");
}
void onShow(AppContext& app, lv_obj_t* parent) override {
wifiSubscription = service::wifi::getPubsub()->subscribe([this](auto event) {
requestViewUpdate();
});
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, ssid.c_str());
busySpinner = lvgl_toolbar_add_spinner_action(toolbar);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lvgl::obj_set_style_bg_invisible(wrapper);
disconnectButton = lv_button_create(wrapper);
lv_obj_set_width(disconnectButton, LV_PCT(100));
lv_obj_add_event_cb(disconnectButton, onPressDisconnect, LV_EVENT_SHORT_CLICKED, nullptr);
auto* disconnect_label = lv_label_create(disconnectButton);
lv_obj_align(disconnect_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(disconnect_label, "Disconnect");
connectButton = lv_button_create(wrapper);
lv_obj_set_width(connectButton, LV_PCT(100));
lv_obj_add_event_cb(connectButton, onPressConnect, LV_EVENT_SHORT_CLICKED, nullptr);
auto* connect_label = lv_label_create(connectButton);
lv_obj_align(connect_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(connect_label, "Connect");
// Forget
auto* forget_button = lv_button_create(wrapper);
lv_obj_set_width(forget_button, LV_PCT(100));
lv_obj_add_event_cb(forget_button, onPressForget, LV_EVENT_SHORT_CLICKED, nullptr);
auto* forget_button_label = lv_label_create(forget_button);
lv_obj_align(forget_button_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(forget_button_label, "Forget");
// Auto-connect
auto* auto_connect_wrapper = lv_obj_create(wrapper);
lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lvgl::obj_set_style_bg_invisible(auto_connect_wrapper);
lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
auto* auto_connect_label = lv_label_create(auto_connect_wrapper);
lv_label_set_text(auto_connect_label, "Auto-connect");
lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper);
lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, this);
lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0);
service::wifi::settings::WifiApSettings settings;
if (service::wifi::settings::load(ssid.c_str(), settings)) {
if (settings.autoConnect) {
lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(auto_connect_switch, LV_STATE_CHECKED);
}
} else {
LOG_W(TAG, "No settings found");
lv_obj_add_flag(forget_button, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(auto_connect_wrapper, LV_OBJ_FLAG_HIDDEN);
}
viewEnabled = true;
updateViews();
}
void onHide(AppContext& app) override {
service::wifi::getPubsub()->unsubscribe(wifiSubscription);
wifiSubscription = nullptr;
viewEnabled = false;
}
void onResult(AppContext& appContext, LaunchId launchId, Result result, std::unique_ptr<Bundle> bundle) override {
if (result != Result::Ok || bundle == nullptr) {
return;
}
auto index = alertdialog::getResultIndex(*bundle);
if (index != 0) { // 0 = Yes
return;
}
auto parameters = appContext.getParameters();
check(parameters != nullptr, "Parameters missing");
std::string ssid = parameters->getString("ssid");
if (!service::wifi::settings::remove(ssid.c_str())) {
LOG_E(TAG, "Failed to remove SSID");
return;
}
LOG_I(TAG, "Removed SSID");
if (
service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive &&
service::wifi::getConnectionTarget() == ssid
) {
service::wifi::disconnect();
}
// Stop app
stop();
}
};
extern const AppManifest manifest = {
.appId = "WifiApSettings",
.appName = "Wi-Fi AP Settings",
.appIcon = LV_SYMBOL_WIFI,
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<WifiApSettings>
};
void updateViews(Context* ctx);
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void onPressForget(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->forgetDialogId = alertdialog::start(ctx->appInstanceId, "Confirmation", "Forget the Wi-Fi access point?", std::vector<std::string> { "Yes", "No" });
}
void onToggleAutoConnect(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
auto* enable_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
service::wifi::settings::WifiApSettings settings;
if (service::wifi::settings::load(ctx->ssid.c_str(), settings)) {
settings.autoConnect = is_on;
if (!service::wifi::settings::save(settings)) {
LOG_E(TAG, "Failed to save settings");
}
} else {
LOG_E(TAG, "Failed to load settings");
}
}
void onPressConnect(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
service::wifi::settings::WifiApSettings settings;
if (service::wifi::settings::load(ctx->ssid.c_str(), settings)) {
auto* button = lv_event_get_target_obj(event);
lv_obj_add_state(button, LV_STATE_DISABLED);
service::wifi::connect(settings, false);
}
}
void onPressDisconnect(lv_event_t*) {
if (service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive) {
service::wifi::disconnect();
}
}
void updateConnectButton(Context* ctx) {
if (service::wifi::getConnectionTarget() == ctx->ssid && service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive) {
lv_obj_remove_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(ctx->connectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(ctx->disconnectButton, LV_STATE_DISABLED);
} else {
lv_obj_add_flag(ctx->disconnectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->connectButton, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(ctx->connectButton, LV_STATE_DISABLED);
}
}
void updateBusySpinner(Context* ctx) {
if (service::wifi::getRadioState() == service::wifi::RadioState::ConnectionPending) {
lv_obj_remove_flag(ctx->busySpinner, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_add_flag(ctx->busySpinner, LV_OBJ_FLAG_HIDDEN);
}
}
void updateViews(Context* ctx) {
updateConnectButton(ctx);
updateBusySpinner(ctx);
}
void requestViewUpdate(Context* ctx) {
lvgl_lock();
updateViews(ctx);
lvgl_unlock();
}
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->wifiSubscription = service::wifi::getPubsub()->subscribe([ctx](auto) {
requestViewUpdate(ctx);
});
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->ssid.c_str());
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
ctx->busySpinner = lvgl_toolbar_add_spinner_action(toolbar);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_border_width(wrapper, 0, LV_STATE_DEFAULT);
lvgl::obj_set_style_bg_invisible(wrapper);
ctx->disconnectButton = lv_button_create(wrapper);
lv_obj_set_width(ctx->disconnectButton, LV_PCT(100));
lv_obj_add_event_cb(ctx->disconnectButton, onPressDisconnect, LV_EVENT_SHORT_CLICKED, ctx);
auto* disconnect_label = lv_label_create(ctx->disconnectButton);
lv_obj_align(disconnect_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(disconnect_label, "Disconnect");
ctx->connectButton = lv_button_create(wrapper);
lv_obj_set_width(ctx->connectButton, LV_PCT(100));
lv_obj_add_event_cb(ctx->connectButton, onPressConnect, LV_EVENT_SHORT_CLICKED, ctx);
auto* connect_label = lv_label_create(ctx->connectButton);
lv_obj_align(connect_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(connect_label, "Connect");
// Forget
auto* forget_button = lv_button_create(wrapper);
lv_obj_set_width(forget_button, LV_PCT(100));
lv_obj_add_event_cb(forget_button, onPressForget, LV_EVENT_SHORT_CLICKED, ctx);
auto* forget_button_label = lv_label_create(forget_button);
lv_obj_align(forget_button_label, LV_ALIGN_CENTER, 0, 0);
lv_label_set_text(forget_button_label, "Forget");
// Auto-connect
auto* auto_connect_wrapper = lv_obj_create(wrapper);
lv_obj_set_size(auto_connect_wrapper, LV_PCT(100), LV_SIZE_CONTENT);
lvgl::obj_set_style_bg_invisible(auto_connect_wrapper);
lv_obj_set_style_pad_all(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(auto_connect_wrapper, 0, LV_STATE_DEFAULT);
auto* auto_connect_label = lv_label_create(auto_connect_wrapper);
lv_label_set_text(auto_connect_label, "Auto-connect");
lv_obj_align(auto_connect_label, LV_ALIGN_LEFT_MID, 0, 0);
auto* auto_connect_switch = lv_switch_create(auto_connect_wrapper);
lv_obj_add_event_cb(auto_connect_switch, onToggleAutoConnect, LV_EVENT_VALUE_CHANGED, ctx);
lv_obj_align(auto_connect_switch, LV_ALIGN_RIGHT_MID, 0, 0);
service::wifi::settings::WifiApSettings settings;
if (service::wifi::settings::load(ctx->ssid.c_str(), settings)) {
if (settings.autoConnect) {
lv_obj_add_state(auto_connect_switch, LV_STATE_CHECKED);
} else {
lv_obj_remove_state(auto_connect_switch, LV_STATE_CHECKED);
}
} else {
LOG_W(TAG, "No settings found");
lv_obj_add_flag(forget_button, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(auto_connect_wrapper, LV_OBJ_FLAG_HIDDEN);
}
updateViews(ctx);
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.ssid = (argc > 0) ? argv[0] : std::string();
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
case APP_EVENT_RESULT:
if (event.result.launch_id == ctx.forgetDialogId && event.result.result == 0) { // 0 = Yes
if (!service::wifi::settings::remove(ctx.ssid.c_str())) {
LOG_E(TAG, "Failed to remove SSID");
} else {
LOG_I(TAG, "Removed SSID");
if (
service::wifi::getRadioState() == service::wifi::RadioState::ConnectionActive &&
service::wifi::getConnectionTarget() == ctx.ssid
) {
service::wifi::disconnect();
}
app_manager_finish(appInstanceId);
shouldClose = true;
}
}
app_manager_stop(event.result.launch_id);
break;
default:
break;
}
}
if (ctx.wifiSubscription != nullptr) {
service::wifi::getPubsub()->unsubscribe(ctx.wifiSubscription);
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
void start(const std::string& ssid) {
const char* argv[] = { ssid.c_str() };
uint32_t instanceId = 0;
app_manager_start_with_parameters(manifest.id, 1, argv, &instanceId);
}
extern const ::AppManifest manifest = {
.id = "WifiApSettings",
.name = "Wi-Fi AP Settings",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
@@ -1,37 +0,0 @@
#include <Tactility/app/wificonnect/State.h>
namespace tt::app::wificonnect {
void State::setConnectionError(bool error) {
lock.lock();
connectionError = error;
lock.unlock();
}
bool State::hasConnectionError() const {
lock.lock();
auto result = connectionError;
lock.unlock();
return result;
}
void State::setApSettings(const service::wifi::settings::WifiApSettings& newSettings) {
lock.lock();
this->apSettings = newSettings;
lock.unlock();
}
void State::setConnecting(bool isConnecting) {
lock.lock();
connecting = isConnecting;
lock.unlock();
}
bool State::isConnecting() const {
lock.lock();
auto result = connecting;
lock.unlock();
return result;
}
} // namespace
-219
View File
@@ -1,219 +0,0 @@
#include <Tactility/TactilityCore.h>
#include <Tactility/app/wificonnect/View.h>
#include <Tactility/app/wificonnect/WifiConnect.h>
#include <Tactility/lvgl/Toolbar.h>
#include <lvgl/widgets/spinner.h>
#include <Tactility/service/wifi/WifiApSettings.h>
#include <Tactility/service/wifi/WifiGlobals.h>
#include <tactility/log.h>
#include <lvgl.h>
#include <cstring>
namespace tt::app::wificonnect {
constexpr auto* TAG = "WifiConnect";
void View::resetErrors() {
lv_obj_add_flag(password_error, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(ssid_error, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(connection_error, LV_OBJ_FLAG_HIDDEN);
}
static void onConnect(lv_event_t* event) {
auto wifi = std::static_pointer_cast<WifiConnect>(getCurrentApp());
auto& view = wifi->getView();
wifi->getState().setConnectionError(false);
view.resetErrors();
const char* ssid = lv_textarea_get_text(view.ssid_textarea);
size_t ssid_len = strlen(ssid);
if (ssid_len > TT_WIFI_SSID_LIMIT) {
LOG_E(TAG, "SSID too long");
lv_label_set_text(view.ssid_error, "SSID too long");
lv_obj_remove_flag(view.ssid_error, LV_OBJ_FLAG_HIDDEN);
return;
}
const char* password = lv_textarea_get_text(view.password_textarea);
size_t password_len = strlen(password);
if (password_len > TT_WIFI_CREDENTIALS_PASSWORD_LIMIT) {
LOG_E(TAG, "Password too long");
lv_label_set_text(view.password_error, "Password too long");
lv_obj_remove_flag(view.password_error, LV_OBJ_FLAG_HIDDEN);
return;
}
bool store = lv_obj_get_state(view.remember_switch) & LV_STATE_CHECKED;
view.setLoading(true);
service::wifi::settings::WifiApSettings settings;
settings.password = password;
settings.ssid = ssid;
settings.channel = 0;
settings.autoConnect = TT_WIFI_AUTO_CONNECT; // No UI yet, so use global setting:w
auto* bindings = &wifi->getBindings();
bindings->onConnectSsid(
settings,
store,
bindings->onConnectSsidContext
);
}
void View::setLoading(bool loading) {
if (loading) {
lv_obj_add_flag(connect_button, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(connecting_spinner, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_state(password_textarea, LV_STATE_DISABLED);
lv_obj_add_state(ssid_textarea, LV_STATE_DISABLED);
lv_obj_add_state(remember_switch, LV_STATE_DISABLED);
} else {
lv_obj_remove_flag(connect_button, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(connecting_spinner, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(password_textarea, LV_STATE_DISABLED);
lv_obj_remove_state(ssid_textarea, LV_STATE_DISABLED);
lv_obj_remove_state(remember_switch, LV_STATE_DISABLED);
}
}
void View::createBottomButtons(lv_obj_t* parent) {
auto* button_container = lv_obj_create(parent);
lv_obj_set_width(button_container, LV_PCT(100));
lv_obj_set_height(button_container, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(button_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(button_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(button_container, 0, LV_STATE_DEFAULT);
remember_switch = lv_switch_create(button_container);
lv_obj_add_state(remember_switch, LV_STATE_CHECKED);
lv_obj_align(remember_switch, LV_ALIGN_LEFT_MID, 0, 0);
auto* remember_label = lv_label_create(button_container);
lv_label_set_text(remember_label, "Remember");
lv_obj_align(remember_label, LV_ALIGN_CENTER, 0, 0);
lv_obj_align_to(remember_label, remember_switch, LV_ALIGN_OUT_RIGHT_MID, 4, 0);
connecting_spinner = lvgl_spinner_create(button_container);
lv_obj_align(connecting_spinner, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_flag(connecting_spinner, LV_OBJ_FLAG_HIDDEN);
connect_button = lv_btn_create(button_container);
auto* connect_label = lv_label_create(connect_button);
lv_label_set_text(connect_label, "Connect");
lv_obj_align(connect_button, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(connect_button, &onConnect, LV_EVENT_SHORT_CLICKED, nullptr);
}
// TODO: Standardize dialogs
void View::init(AppContext& app, lv_obj_t* parent) {
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
lvgl::toolbar_create(parent, app);
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
// SSID
auto* ssid_wrapper = lv_obj_create(wrapper);
lv_obj_set_width(ssid_wrapper, LV_PCT(100));
lv_obj_set_height(ssid_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ssid_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(ssid_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ssid_wrapper, 0, LV_STATE_DEFAULT);
auto* ssid_label_wrapper = lv_obj_create(ssid_wrapper);
lv_obj_set_width(ssid_label_wrapper, LV_PCT(50));
lv_obj_set_height(ssid_label_wrapper, LV_SIZE_CONTENT);
lv_obj_align(ssid_label_wrapper, LV_ALIGN_LEFT_MID, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ssid_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_left(ssid_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(ssid_label_wrapper, 0, LV_STATE_DEFAULT);
auto* ssid_label = lv_label_create(ssid_label_wrapper);
lv_label_set_text(ssid_label, "Network:");
ssid_textarea = lv_textarea_create(ssid_wrapper);
lv_textarea_set_one_line(ssid_textarea, true);
lv_obj_align(ssid_textarea, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_width(ssid_textarea, LV_PCT(50));
ssid_error = lv_label_create(wrapper);
lv_obj_set_style_text_color(ssid_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT);
lv_obj_add_flag(ssid_error, LV_OBJ_FLAG_HIDDEN);
// Password
auto* password_wrapper = lv_obj_create(wrapper);
lv_obj_set_width(password_wrapper, LV_PCT(100));
lv_obj_set_height(password_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(password_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(password_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(password_wrapper, 0, LV_STATE_DEFAULT);
auto* password_label_wrapper = lv_obj_create(password_wrapper);
lv_obj_set_width(password_label_wrapper, LV_PCT(50));
lv_obj_set_height(password_label_wrapper, LV_SIZE_CONTENT);
lv_obj_align_to(password_label_wrapper, password_wrapper, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_set_style_border_width(password_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_left(password_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(password_label_wrapper, 0, LV_STATE_DEFAULT);
auto* password_label = lv_label_create(password_label_wrapper);
lv_label_set_text(password_label, "Password:");
password_textarea = lv_textarea_create(password_wrapper);
lv_textarea_set_one_line(password_textarea, true);
lv_textarea_set_password_mode(password_textarea, true);
lv_obj_align(password_textarea, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_width(password_textarea, LV_PCT(50));
password_error = lv_label_create(wrapper);
lv_obj_set_style_text_color(password_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT);
lv_obj_add_flag(password_error, LV_OBJ_FLAG_HIDDEN);
// Connection error
connection_error = lv_label_create(wrapper);
lv_obj_set_style_text_color(connection_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT);
lv_obj_add_flag(connection_error, LV_OBJ_FLAG_HIDDEN);
// Bottom buttons
createBottomButtons(wrapper);
// Init from app parameters
auto bundle = app.getParameters();
if (bundle != nullptr) {
std::string ssid;
if (optSsidParameter(bundle, ssid)) {
lv_textarea_set_text(ssid_textarea, ssid.c_str());
if (!ssid.empty()) {
lv_group_focus_obj(password_textarea);
}
}
std::string password;
if (optPasswordParameter(bundle, password)) {
lv_textarea_set_text(password_textarea, password.c_str());
}
}
}
void View::update() {
if (state->hasConnectionError()) {
setLoading(false);
resetErrors();
lv_label_set_text(connection_error, "Connection failed");
lv_obj_remove_flag(connection_error, LV_OBJ_FLAG_HIDDEN);
}
}
} // namespace
+309 -76
View File
@@ -1,115 +1,348 @@
#include <Tactility/app/wificonnect/WifiConnect.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/service/wifi/Wifi.h>
#include <Tactility/service/wifi/WifiApSettings.h>
#include <Tactility/service/wifi/WifiGlobals.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/log.h>
#include <lvgl.h>
#include <cstring>
namespace tt::app::wificonnect {
constexpr auto* TAG = "WifiConnect";
constexpr auto* WIFI_CONNECT_PARAM_SSID = "ssid"; // String
constexpr auto* WIFI_CONNECT_PARAM_PASSWORD = "password"; // String
extern const AppManifest manifest;
extern const ::AppManifest manifest;
static void onConnect(const service::wifi::settings::WifiApSettings& ap_settings, bool remember, void* parameter) {
auto* wifi = static_cast<WifiConnect*>(parameter);
wifi->getState().setApSettings(ap_settings);
wifi->getState().setConnecting(true);
service::wifi::connect(ap_settings, remember);
namespace {
struct Context {
uint32_t appInstanceId;
std::string initialSsid;
std::string initialPassword;
// Touched only from the LVGL task: directly by onConnectPressed() (an LVGL event callback,
// which already runs with the LVGL lock held), and by onWifiEvent() (a wifi-pubsub
// callback running on some other thread) which explicitly wraps its touches in
// lvgl_lock()/lvgl_unlock() - see WifiApSettings.cpp for the same convention.
bool connecting = false;
bool connectionError = false;
lv_obj_t* ssid_textarea = nullptr;
lv_obj_t* ssid_error = nullptr;
lv_obj_t* password_textarea = nullptr;
lv_obj_t* password_error = nullptr;
lv_obj_t* connect_button = nullptr;
lv_obj_t* remember_switch = nullptr;
lv_obj_t* connecting_spinner = nullptr;
lv_obj_t* connection_error = nullptr;
PubSub<service::wifi::WifiEvent>::SubscriptionHandle wifiSubscription = nullptr;
};
void updateView(Context* ctx);
void resetErrors(Context* ctx);
void setLoading(Context* ctx, bool loading);
void onBackPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
void WifiConnect::onWifiEvent(service::wifi::WifiEvent event) {
State& state = getState();
// Runs on the wifi service's pubsub thread, not the LVGL task or this app's own thread.
void onWifiEvent(Context* ctx, service::wifi::WifiEvent event) {
bool shouldClose = false;
lvgl_lock();
if (event.type == WIFI_EVENT_TYPE_STATION_CONNECTION_RESULT) {
if (event.connection_error == WIFI_STATION_CONNECTION_ERROR_NONE) {
if (state.isConnecting()) {
state.setConnecting(false);
stop(manifest.appId);
if (ctx->connecting) {
ctx->connecting = false;
shouldClose = true;
}
} else {
if (state.isConnecting()) {
state.setConnecting(false);
state.setConnectionError(true);
requestViewUpdate();
if (ctx->connecting) {
ctx->connecting = false;
ctx->connectionError = true;
updateView(ctx);
}
}
}
requestViewUpdate();
updateView(ctx);
lvgl_unlock();
if (shouldClose) {
// Async, non-blocking - same reasoning as onBackPressed() (must not call
// app_manager_stop() on ourselves); safe to call from any thread.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(ctx->appInstanceId, &closeEvent);
}
}
WifiConnect::WifiConnect() {
wifiSubscription = service::wifi::getPubsub()->subscribe([this](auto event) {
onWifiEvent(event);
void resetErrors(Context* ctx) {
lv_obj_add_flag(ctx->password_error, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(ctx->ssid_error, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(ctx->connection_error, LV_OBJ_FLAG_HIDDEN);
}
void setLoading(Context* ctx, bool loading) {
if (loading) {
lv_obj_add_flag(ctx->connect_button, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_state(ctx->password_textarea, LV_STATE_DISABLED);
lv_obj_add_state(ctx->ssid_textarea, LV_STATE_DISABLED);
lv_obj_add_state(ctx->remember_switch, LV_STATE_DISABLED);
} else {
lv_obj_remove_flag(ctx->connect_button, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(ctx->password_textarea, LV_STATE_DISABLED);
lv_obj_remove_state(ctx->ssid_textarea, LV_STATE_DISABLED);
lv_obj_remove_state(ctx->remember_switch, LV_STATE_DISABLED);
}
}
void updateView(Context* ctx) {
if (ctx->connectionError) {
setLoading(ctx, false);
resetErrors(ctx);
lv_label_set_text(ctx->connection_error, "Connection failed");
lv_obj_remove_flag(ctx->connection_error, LV_OBJ_FLAG_HIDDEN);
}
}
void onConnectPressed(lv_event_t* event) {
auto* ctx = static_cast<Context*>(lv_event_get_user_data(event));
ctx->connectionError = false;
resetErrors(ctx);
const char* ssid = lv_textarea_get_text(ctx->ssid_textarea);
size_t ssid_len = strlen(ssid);
if (ssid_len > TT_WIFI_SSID_LIMIT) {
LOG_E(TAG, "SSID too long");
lv_label_set_text(ctx->ssid_error, "SSID too long");
lv_obj_remove_flag(ctx->ssid_error, LV_OBJ_FLAG_HIDDEN);
return;
}
const char* password = lv_textarea_get_text(ctx->password_textarea);
size_t password_len = strlen(password);
if (password_len > TT_WIFI_CREDENTIALS_PASSWORD_LIMIT) {
LOG_E(TAG, "Password too long");
lv_label_set_text(ctx->password_error, "Password too long");
lv_obj_remove_flag(ctx->password_error, LV_OBJ_FLAG_HIDDEN);
return;
}
bool store = lv_obj_get_state(ctx->remember_switch) & LV_STATE_CHECKED;
setLoading(ctx, true);
service::wifi::settings::WifiApSettings settings;
settings.password = password;
settings.ssid = ssid;
settings.channel = 0;
settings.autoConnect = TT_WIFI_AUTO_CONNECT; // No UI yet, so use global setting
ctx->connecting = true;
service::wifi::connect(settings, store);
}
void createBottomButtons(Context* ctx, lv_obj_t* parent) {
auto* button_container = lv_obj_create(parent);
lv_obj_set_width(button_container, LV_PCT(100));
lv_obj_set_height(button_container, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(button_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(button_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(button_container, 0, LV_STATE_DEFAULT);
ctx->remember_switch = lv_switch_create(button_container);
lv_obj_add_state(ctx->remember_switch, LV_STATE_CHECKED);
lv_obj_align(ctx->remember_switch, LV_ALIGN_LEFT_MID, 0, 0);
auto* remember_label = lv_label_create(button_container);
lv_label_set_text(remember_label, "Remember");
lv_obj_align(remember_label, LV_ALIGN_CENTER, 0, 0);
lv_obj_align_to(remember_label, ctx->remember_switch, LV_ALIGN_OUT_RIGHT_MID, 4, 0);
ctx->connecting_spinner = lvgl_spinner_create(button_container);
lv_obj_align(ctx->connecting_spinner, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_flag(ctx->connecting_spinner, LV_OBJ_FLAG_HIDDEN);
ctx->connect_button = lv_btn_create(button_container);
auto* connect_label = lv_label_create(ctx->connect_button);
lv_label_set_text(connect_label, "Connect");
lv_obj_align(ctx->connect_button, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_add_event_cb(ctx->connect_button, onConnectPressed, LV_EVENT_SHORT_CLICKED, ctx);
}
// TODO: Standardize dialogs
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->wifiSubscription = service::wifi::getPubsub()->subscribe([ctx](auto event) {
onWifiEvent(ctx, event);
});
bindings = (Bindings) {
.onConnectSsid = onConnect,
.onConnectSsidContext = this,
};
}
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
WifiConnect::~WifiConnect() {
service::wifi::getPubsub()->unsubscribe(wifiSubscription);
}
auto* toolbar = lvgl_toolbar_create(parent, "Wi-Fi Connect");
// The global toolbar nav callback only knows how to stop old-model apps.
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx);
void WifiConnect::lock() {
mutex.lock();
}
auto* wrapper = lv_obj_create(parent);
lv_obj_set_width(wrapper, LV_PCT(100));
lv_obj_set_flex_grow(wrapper, 1);
lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN);
void WifiConnect::unlock() {
mutex.unlock();
}
// SSID
void WifiConnect::requestViewUpdate() {
lock();
if (viewEnabled) {
lvgl_lock();
view.update();
lvgl_unlock();
auto* ssid_wrapper = lv_obj_create(wrapper);
lv_obj_set_width(ssid_wrapper, LV_PCT(100));
lv_obj_set_height(ssid_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(ssid_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(ssid_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ssid_wrapper, 0, LV_STATE_DEFAULT);
auto* ssid_label_wrapper = lv_obj_create(ssid_wrapper);
lv_obj_set_width(ssid_label_wrapper, LV_PCT(50));
lv_obj_set_height(ssid_label_wrapper, LV_SIZE_CONTENT);
lv_obj_align(ssid_label_wrapper, LV_ALIGN_LEFT_MID, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(ssid_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_left(ssid_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(ssid_label_wrapper, 0, LV_STATE_DEFAULT);
auto* ssid_label = lv_label_create(ssid_label_wrapper);
lv_label_set_text(ssid_label, "Network:");
ctx->ssid_textarea = lv_textarea_create(ssid_wrapper);
lv_textarea_set_one_line(ctx->ssid_textarea, true);
lv_obj_align(ctx->ssid_textarea, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_width(ctx->ssid_textarea, LV_PCT(50));
ctx->ssid_error = lv_label_create(wrapper);
lv_obj_set_style_text_color(ctx->ssid_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT);
lv_obj_add_flag(ctx->ssid_error, LV_OBJ_FLAG_HIDDEN);
// Password
auto* password_wrapper = lv_obj_create(wrapper);
lv_obj_set_width(password_wrapper, LV_PCT(100));
lv_obj_set_height(password_wrapper, LV_SIZE_CONTENT);
lv_obj_set_style_pad_all(password_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(password_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(password_wrapper, 0, LV_STATE_DEFAULT);
auto* password_label_wrapper = lv_obj_create(password_wrapper);
lv_obj_set_width(password_label_wrapper, LV_PCT(50));
lv_obj_set_height(password_label_wrapper, LV_SIZE_CONTENT);
lv_obj_align_to(password_label_wrapper, password_wrapper, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_set_style_border_width(password_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_left(password_label_wrapper, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_right(password_label_wrapper, 0, LV_STATE_DEFAULT);
auto* password_label = lv_label_create(password_label_wrapper);
lv_label_set_text(password_label, "Password:");
ctx->password_textarea = lv_textarea_create(password_wrapper);
lv_textarea_set_one_line(ctx->password_textarea, true);
lv_textarea_set_password_mode(ctx->password_textarea, true);
lv_obj_align(ctx->password_textarea, LV_ALIGN_RIGHT_MID, 0, 0);
lv_obj_set_width(ctx->password_textarea, LV_PCT(50));
ctx->password_error = lv_label_create(wrapper);
lv_obj_set_style_text_color(ctx->password_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT);
lv_obj_add_flag(ctx->password_error, LV_OBJ_FLAG_HIDDEN);
// Connection error
ctx->connection_error = lv_label_create(wrapper);
lv_obj_set_style_text_color(ctx->connection_error, lv_color_make(255, 50, 50), LV_STATE_DEFAULT);
lv_obj_add_flag(ctx->connection_error, LV_OBJ_FLAG_HIDDEN);
// Bottom buttons
createBottomButtons(ctx, wrapper);
// Init from app parameters
if (!ctx->initialSsid.empty()) {
lv_textarea_set_text(ctx->ssid_textarea, ctx->initialSsid.c_str());
lv_group_focus_obj(ctx->password_textarea);
}
if (!ctx->initialPassword.empty()) {
lv_textarea_set_text(ctx->password_textarea, ctx->initialPassword.c_str());
}
unlock();
}
void WifiConnect::onShow(AppContext& app, lv_obj_t* parent) {
lock();
viewEnabled = true;
view.init(app, parent);
view.update();
unlock();
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
void WifiConnect::onHide(AppContext& app) {
// No need to lock view, as this is called from within Gui's LVGL context
lock();
viewEnabled = false;
unlock();
}
Context ctx {};
ctx.appInstanceId = appInstanceId;
ctx.initialSsid = (argc > 0) ? argv[0] : std::string();
ctx.initialPassword = (argc > 1) ? argv[1] : std::string();
extern const AppManifest manifest = {
.appId = "WifiConnect",
.appName = "Wi-Fi Connect",
.appIcon = LV_SYMBOL_WIFI,
.appCategory = Category::System,
.appFlags = AppManifest::Flags::Hidden,
.createApp = create<WifiConnect>
};
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
LaunchId start(const std::string& ssid, const std::string& password) {
auto parameters = std::make_shared<Bundle>();
parameters->putString(WIFI_CONNECT_PARAM_SSID, ssid);
parameters->putString(WIFI_CONNECT_PARAM_PASSWORD, password);
return app::start(manifest.appId, parameters);
}
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
bool optSsidParameter(const std::shared_ptr<const Bundle>& bundle, std::string& ssid) {
return bundle->optString(WIFI_CONNECT_PARAM_SSID, ssid);
}
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
bool optPasswordParameter(const std::shared_ptr<const Bundle>& bundle, std::string& password) {
return bundle->optString(WIFI_CONNECT_PARAM_PASSWORD, password);
if (ctx.wifiSubscription != nullptr) {
service::wifi::getPubsub()->unsubscribe(ctx.wifiSubscription);
}
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
void start(const std::string& ssid, const std::string& password) {
const char* argv[] = { ssid.c_str(), password.c_str() };
uint32_t instanceId = 0;
app_manager_start_with_parameters(manifest.id, 2, argv, &instanceId);
}
extern const ::AppManifest manifest = {
.id = "WifiConnect",
.name = "Wi-Fi Connect",
.category = APP_CATEGORY_SYSTEM,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) },
.flags = APP_MANIFEST_FLAG_HIDDEN,
};
} // namespace
+18 -10
View File
@@ -11,6 +11,9 @@
#include <Tactility/service/wifi/WifiSettings.h>
#include <Tactility/Tactility.h>
#include <app/event.h>
#include <lvgl/widgets/toolbar.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
@@ -18,7 +21,15 @@ namespace tt::app::wifimanage {
constexpr auto* TAG = "WifiManageView";
std::shared_ptr<WifiManage> optWifiManage();
static void onBackPressed(lv_event_t* event) {
auto* appInstanceId = static_cast<uint32_t*>(lv_event_get_user_data(event));
// Async, non-blocking - must NOT call app_manager_stop() directly here: that bound-waits
// (thread_join) for this app's own thread to finish, which needs the LVGL lock
// (window_manager_remove()) - but this callback runs ON the LVGL task, which would
// deadlock against itself.
AppEvent closeEvent { .type = APP_EVENT_CLOSE, .timestamp = 0, .result = {} };
app_event_emit(*appInstanceId, &closeEvent);
}
static uint8_t mapRssiToPercentage(int rssi) {
auto abs_rssi = std::abs(rssi);
@@ -35,11 +46,8 @@ static uint8_t mapRssiToPercentage(int rssi) {
static void onEnableSwitchChanged(lv_event_t* event) {
auto* enable_switch = static_cast<lv_obj_t*>(lv_event_get_target(event));
bool is_on = lv_obj_has_state(enable_switch, LV_STATE_CHECKED);
auto wifi = std::static_pointer_cast<WifiManage>(getCurrentApp());
auto bindings = wifi->getBindings();
bindings.onWifiToggled(is_on);
auto* bindings = static_cast<Bindings*>(lv_event_get_user_data(event));
bindings->onWifiToggled(is_on);
}
static void onEnableOnBootSwitchChanged(lv_event_t* event) {
@@ -289,18 +297,18 @@ void View::updateEnableOnBootToggle() {
// region Main
void View::init(const AppContext& app, lv_obj_t* parent) {
void View::init(uint32_t newAppInstanceId, lv_obj_t* parent) {
appInstanceId = newAppInstanceId;
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT);
root = parent;
paths = app.getPaths();
// Toolbar
lv_obj_t* toolbar = lvgl::toolbar_create(parent, app);
lv_obj_t* toolbar = lvgl_toolbar_create(parent, "Wi-Fi");
lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, &appInstanceId);
scanning_spinner = lvgl_toolbar_add_spinner_action(toolbar);
+102 -66
View File
@@ -1,21 +1,39 @@
#include <Tactility/app/wifimanage/View.h>
#include <Tactility/app/wifimanage/WifiManagePrivate.h>
#include <Tactility/app/AppContext.h>
#include <Tactility/app/wifiapsettings/WifiApSettings.h>
#include <Tactility/app/wificonnect/WifiConnect.h>
#include <Tactility/service/loader/Loader.h>
#include <app/event.h>
#include <app/manager.h>
#include <app/manifest.h>
#include <lvgl_window_manager/window_manager.h>
#include <tactility/log.h>
#include <lvgl/icons/shared.h>
#include <lvgl/lvgl.h>
namespace tt::app::wifimanage {
constexpr auto* TAG = "WifiManage";
extern const AppManifest manifest;
extern const ::AppManifest manifest;
namespace {
struct Context {
uint32_t appInstanceId;
PubSub<service::wifi::WifiEvent>::SubscriptionHandle wifiSubscription = nullptr;
Mutex mutex;
Bindings bindings {};
State state;
View view = View(&bindings, &state);
void lock() { mutex.lock(); }
void unlock() { mutex.unlock(); }
};
static void onConnect(const std::string& ssid) {
service::wifi::settings::WifiApSettings settings;
@@ -44,45 +62,25 @@ static void onConnectToHidden() {
wificonnect::start();
}
WifiManage::WifiManage() {
bindings = (Bindings) {
.onWifiToggled = onWifiToggled,
.onConnectSsid = onConnect,
.onDisconnect = onDisconnect,
.onShowApSettings = onShowApSettings,
.onConnectToHidden = onConnectToHidden
};
void requestViewUpdate(Context* ctx) {
ctx->lock();
lvgl_lock();
ctx->view.update();
lvgl_unlock();
ctx->unlock();
}
void WifiManage::lock() {
mutex.lock();
}
void WifiManage::unlock() {
mutex.unlock();
}
void WifiManage::requestViewUpdate() {
lock();
if (isViewEnabled) {
lvgl_lock();
view.update();
lvgl_unlock();
}
unlock();
}
void WifiManage::onWifiEvent(service::wifi::WifiEvent event) {
void onWifiEvent(Context* ctx, service::wifi::WifiEvent event) {
auto radio_state = service::wifi::getRadioState();
LOG_I(TAG, "Update with state %s", service::wifi::radioStateToString(radio_state));
getState().setRadioState(radio_state);
ctx->state.setRadioState(radio_state);
switch (event.type) {
case WIFI_EVENT_TYPE_SCAN_STARTED:
getState().setScanning(true);
ctx->state.setScanning(true);
break;
case WIFI_EVENT_TYPE_SCAN_FINISHED:
getState().setScanning(false);
getState().updateApRecords();
ctx->state.setScanning(false);
ctx->state.updateApRecords();
break;
case WIFI_EVENT_TYPE_RADIO_STATE_CHANGED:
if (event.radio_state == WIFI_RADIO_STATE_ON && !service::wifi::isScanning()) {
@@ -93,26 +91,43 @@ void WifiManage::onWifiEvent(service::wifi::WifiEvent event) {
break;
}
requestViewUpdate();
requestViewUpdate(ctx);
}
void WifiManage::onShow(AppContext& app, lv_obj_t* parent) {
wifiSubscription = service::wifi::getPubsub()->subscribe([this](auto event) {
onWifiEvent(event);
void createWidgets(lv_obj_t* parent, void* userData) {
auto* ctx = static_cast<Context*>(userData);
ctx->lock();
ctx->state.setConnectSsid("Connected"); // TODO update with proper SSID
ctx->view.init(ctx->appInstanceId, parent);
ctx->view.update();
ctx->unlock();
}
int32_t appMain(uint32_t appInstanceId, int argc, char* argv[]) {
Context ctx;
ctx.appInstanceId = appInstanceId;
ctx.bindings = (Bindings) {
.onWifiToggled = onWifiToggled,
.onConnectSsid = onConnect,
.onDisconnect = onDisconnect,
.onShowApSettings = onShowApSettings,
.onConnectToHidden = onConnectToHidden
};
ctx.wifiSubscription = service::wifi::getPubsub()->subscribe([&ctx](auto event) {
onWifiEvent(&ctx, event);
});
// State update (it has its own locking)
state.setRadioState(service::wifi::getRadioState());
state.setScanning(service::wifi::isScanning());
state.updateApRecords();
ctx.state.setRadioState(service::wifi::getRadioState());
ctx.state.setScanning(service::wifi::isScanning());
ctx.state.updateApRecords();
// View update
lock();
isViewEnabled = true;
state.setConnectSsid("Connected"); // TODO update with proper SSID
view.init(app, parent);
view.update();
unlock();
AppEventSubscription sub {};
sub.app_instance_id = appInstanceId;
app_event_subscribe(&sub);
WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx);
service::wifi::RadioState radio_state = service::wifi::getRadioState();
bool can_scan = radio_state == service::wifi::RadioState::On ||
@@ -127,26 +142,47 @@ void WifiManage::onShow(AppContext& app, lv_obj_t* parent) {
if (can_scan && !service::wifi::isScanning()) {
service::wifi::scan();
}
}
void WifiManage::onHide(AppContext& app) {
lock();
service::wifi::getPubsub()->unsubscribe(wifiSubscription);
wifiSubscription = nullptr;
isViewEnabled = false;
unlock();
}
bool shouldClose = false;
while (!shouldClose) {
AppEvent event {};
if (app_event_await(&sub, &event, portMAX_DELAY) != ERROR_NONE) {
break;
}
switch (event.type) {
case APP_EVENT_CLOSE:
app_manager_finish(appInstanceId);
shouldClose = true;
break;
default:
break;
}
}
extern const AppManifest manifest = {
.appId = "WifiManage",
.appName = "Wi-Fi",
.appIcon = LVGL_ICON_SHARED_WIFI,
.appCategory = Category::Settings,
.createApp = create<WifiManage>
};
ctx.lock();
service::wifi::getPubsub()->unsubscribe(ctx.wifiSubscription);
ctx.wifiSubscription = nullptr;
ctx.unlock();
LaunchId start() {
return app::start(manifest.appId);
window_manager_remove(window);
app_event_unsubscribe(&sub);
return 0;
}
} // namespace
uint32_t start(uint32_t callerAppInstanceId) {
uint32_t instanceId = 0;
app_manager_start_for_result(manifest.id, callerAppInstanceId, 0, nullptr, &instanceId);
return instanceId;
}
extern const ::AppManifest manifest = {
.id = "WifiManage",
.name = "Wi-Fi",
.category = APP_CATEGORY_SETTINGS,
.location = { APP_LOCATION_MEMORY, reinterpret_cast<void*>(appMain) }
};
} // namespace tt::app::wifimanage
@@ -1,6 +1,6 @@
#include <Tactility/bluetooth/BluetoothPairedDevice.h>
#include "Tactility/Paths.h"
#include "Tactility/DeprecatedPaths.h"
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
@@ -1,9 +1,9 @@
#include <Tactility/bluetooth/BluetoothSettings.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/Mutex.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/Mutex.h>
#include <Tactility/Paths.h>
#include <tactility/log.h>
namespace tt::bluetooth::settings {
+24
View File
@@ -1,5 +1,6 @@
#include <tactility/device.h>
#include <tactility/drivers/display.h>
#include <tactility/drivers/sdcard.h>
#include <tactility/drivers/spi_controller.h>
#include <tactility/filesystem/file_mutex.h>
#include <tactility/filesystem/file_system.h>
@@ -75,6 +76,29 @@ void initFileMutexForLvgl() {
return true;
});
// SDMMC-backed SD cards aren't parented under SPI_CONTROLLER_TYPE, so the pass above never
// sees them - but on some chips (classic ESP32) SDMMC and SPI still contend for DMA/bus
// access. Lock every SD card mount if a display exists anywhere, regardless of bus topology.
if (!device_exists_of_type(&DISPLAY_TYPE)) {
return;
}
file_system_for_each(nullptr, [](FileSystem* fs, void* context) {
char mount_path[64];
if (file_system_get_path(fs, mount_path, sizeof(mount_path)) != ERROR_NONE) {
return true;
}
auto* owner = file_system_get_owner(fs);
if (owner == nullptr || device_get_type(owner) != &SDCARD_TYPE) {
return true;
}
LOG_I(TAG, "Adding file mutex for %s (SD card) - a display is present and may contend for bus/DMA resources", mount_path);
file_mutex_register(&lvgl_mutex, mount_path);
return true;
});
}
}
+24 -46
View File
@@ -1,50 +1,31 @@
#include "Tactility/file/PropertiesFile.h"
#include <Tactility/StringUtils.h>
#include <Tactility/file/File.h>
#include <tactility/log.h>
#include <tactility/properties_file.h>
namespace tt::file {
constexpr auto* TAG = "PropertiesFile";
bool getKeyValuePair(const std::string& input, std::string& key, std::string& value) {
auto index = input.find('=');
if (index == std::string::npos) {
bool loadPropertiesFile(const std::string& filePath, std::function<void(const std::string& key, const std::string& value)> callback) {
// Matches the original semantics: a missing file is a real failure the caller checks for
// (e.g. "no saved settings yet"), unlike properties_file_open() itself, which treats a
// missing file as a fresh, empty store to be created on close().
if (!isFile(filePath)) {
return false;
}
key = input.substr(0, index);
value = input.substr(index + 1);
return true;
}
bool loadPropertiesFile(const std::string& filePath, std::function<void(const std::string& key, const std::string& value)> callback) {
// Reading properties is a common operation; make this debug-level to avoid
// flooding the serial console under frequent polling.
LOG_D(TAG, "Reading properties file %s", filePath.c_str());
uint16_t line_count = 0;
std::string key_prefix = "";
// Malformed lines are skipped, valid lines are loaded and callback is called
return readLines(filePath, true, [&key_prefix, &line_count, &filePath, &callback](const std::string& line) {
line_count++;
std::string key, value;
// Trim all whitespace including \r\n (Windows line endings)
auto trimmed_line = string::trim(line, " \t\r\n");
if (!trimmed_line.starts_with("#") && !trimmed_line.empty()) {
if (trimmed_line.starts_with("[")) {
key_prefix = trimmed_line;
} else {
if (getKeyValuePair(trimmed_line, key, value)) {
std::string trimmed_key = key_prefix + string::trim(key, " \t");
std::string trimmed_value = string::trim(value, " \t");
callback(trimmed_key, trimmed_value);
} else {
LOG_E(TAG, "Failed to parse line %d of %s (skipped)", line_count, filePath.c_str());
// Continue loading other lines
}
}
}
});
PropertiesFile* file = properties_file_open(filePath.c_str());
if (file == nullptr) {
return false;
}
properties_file_for_each(file, [](const char* key, const char* value, void* context) {
auto* typed_callback = static_cast<std::function<void(const std::string&, const std::string&)>*>(context);
(*typed_callback)(key, value);
}, &callback);
properties_file_close(file);
return true;
}
bool loadPropertiesFile(const std::string& filePath, std::map<std::string, std::string>& outProperties) {
@@ -54,19 +35,16 @@ bool loadPropertiesFile(const std::string& filePath, std::map<std::string, std::
}
bool savePropertiesFile(const std::string& filePath, const std::map<std::string, std::string>& properties) {
FileMutexGuard guard(filePath);
LOG_I(TAG, "Saving properties file %s", filePath.c_str());
FILE* file = fopen(filePath.c_str(), "w");
PropertiesFile* file = properties_file_open(filePath.c_str());
if (file == nullptr) {
LOG_E(TAG, "Failed to open %s", filePath.c_str());
return false;
}
for (const auto& [key, value]: properties) { fprintf(file, "%s=%s\n", key.c_str(), value.c_str()); }
for (const auto& [key, value] : properties) {
properties_file_set(file, key.c_str(), value.c_str());
}
fclose(file);
properties_file_close(file);
return true;
}
+1 -1
View File
@@ -133,7 +133,7 @@ static void statusbar_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj)
if (!statusbar_data.time_update_timer->isRunning()) {
statusbar_data.time_update_timer->start();
system_event_subscribe(KERNEL_EVENT_TIME_CHANGED, onTimeChanged, nullptr);
system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, onTimeChanged, nullptr);
}
}
-9
View File
@@ -1,10 +1 @@
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/app/AppManifest.h>
namespace tt::lvgl {
lv_obj_t* toolbar_create(lv_obj_t* parent, const app::AppContext& app) {
return lvgl_toolbar_create(parent, app.getManifest().appName.c_str());
}
} // namespace
-3
View File
@@ -2,8 +2,6 @@
#include <Tactility/file/File.h>
#include <Tactility/network/Http.h>
#include "Tactility/service/gui/GuiService.h"
#include <tactility/log.h>
#ifdef ESP_PLATFORM
@@ -22,7 +20,6 @@ void download(
const std::function<void()>& onSuccess,
const std::function<void(const char* errorMessage)>& onError
) {
service::gui::warnIfRunningOnGuiTask("HTTP");
LOG_I(TAG, "Downloading from %s to %s", url.c_str(), downloadFilePath.c_str());
#ifdef ESP_PLATFORM
getMainDispatcher().dispatch([url, certFilePath, downloadFilePath, onSuccess, onError] {
+35 -6
View File
@@ -1,8 +1,8 @@
#include <Tactility/LogMessages.h>
#include <Tactility/StringUtils.h>
#include <Tactility/file/File.h>
#include <Tactility/network/HttpdReq.h>
#include <tactility/filesystem/file_mutex.h>
#include <tactility/log.h>
#include <memory>
@@ -186,30 +186,59 @@ size_t receiveFile(httpd_req_t* request, size_t length, const std::string& fileP
char buffer[BUFFER_SIZE];
size_t bytes_received = 0;
file::FileMutexGuard guard(filePath);
// Locked only around each actual disk I/O call below, not across the httpd_req_recv() waits
// in between - this file's mutex may resolve to lvgl_lock() (see FileMutexLvgl.cpp), and
// holding that for the whole (potentially multi-second) network transfer starves LVGL's own
// task for the entire upload instead of just for each brief write.
FileMutex mutex {};
file_mutex_get(&mutex, filePath.c_str());
file_mutex_lock(&mutex);
auto* file = fopen(filePath.c_str(), "wb");
file_mutex_unlock(&mutex);
if (file == nullptr) {
LOG_E(TAG, "Failed to open file for writing: %s", filePath.c_str());
return 0;
}
constexpr int MAX_TIMEOUT_RETRIES = 5;
int timeout_retries = 0;
while (bytes_received < length) {
auto expected_chunk_size = std::min<size_t>(BUFFER_SIZE, length - bytes_received);
size_t receive_chunk_size = httpd_req_recv(request, buffer, expected_chunk_size);
if (receive_chunk_size <= 0) {
int received = httpd_req_recv(request, buffer, expected_chunk_size);
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
// Timeout - retry with backoff, same as receiveByteArray(). A large file takes many
// more chunks (and much longer overall) than the small reads elsewhere in this file,
// so it's far more likely to hit at least one transient stall somewhere along the way.
timeout_retries++;
if (timeout_retries >= MAX_TIMEOUT_RETRIES) {
LOG_E(TAG, "Recv timeout after %d retries, wrote %zu/%zu bytes", timeout_retries, bytes_received, length);
break;
}
LOG_W(TAG, "Recv timeout, retry %d/%d", timeout_retries, MAX_TIMEOUT_RETRIES);
vTaskDelay(pdMS_TO_TICKS(100 * timeout_retries)); // Exponential backoff
continue;
}
if (received <= 0) {
LOG_E(TAG, "Receive failed, got 0 bytes but expected %zu more", length - bytes_received);
break;
}
if (fwrite(buffer, 1, receive_chunk_size, file) != receive_chunk_size) {
timeout_retries = 0;
size_t receive_chunk_size = (size_t)received;
file_mutex_lock(&mutex);
bool write_ok = fwrite(buffer, 1, receive_chunk_size, file) == receive_chunk_size;
file_mutex_unlock(&mutex);
if (!write_ok) {
LOG_E(TAG, "Failed to write all bytes");
break;
}
bytes_received += receive_chunk_size;
}
// Write file
file_mutex_lock(&mutex);
fclose(file);
file_mutex_unlock(&mutex);
return bytes_received;
}
+33 -7
View File
@@ -1,9 +1,10 @@
#include <Tactility/network/NtpPrivate.h>
#include <Tactility/Preferences.h>
#include <tactility/log.h>
#include <tactility/paths.h>
#include <tactility/preferences.h>
#include <memory>
#include <string>
#ifdef ESP_PLATFORM
#include <Tactility/TactilityCore.h>
@@ -20,24 +21,49 @@ static bool processedSyncEvent = false;
#ifdef ESP_PLATFORM
static bool getPreferencesPath(std::string& outPath) {
char root[128];
if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) {
return false;
}
outPath = std::string(root) + "/time.properties";
return true;
}
void storeTimeInNvs() {
time_t now;
time(&now);
auto preferences = std::make_unique<Preferences>("time");
preferences->putInt64("syncTime", now);
std::string path;
if (!getPreferencesPath(path)) {
return;
}
Preferences* preferences = preferences_open(path.c_str());
if (preferences == nullptr) {
return;
}
preferences_put_int64(preferences, "syncTime", now);
preferences_close(preferences);
LOG_I(TAG, "Stored time %ld", (long)now);
}
void setTimeFromNvs() {
auto preferences = std::make_unique<Preferences>("time");
time_t synced_time;
if (preferences->optInt64("syncTime", synced_time)) {
std::string path;
if (!getPreferencesPath(path)) {
return;
}
Preferences* preferences = preferences_open(path.c_str());
if (preferences == nullptr) {
return;
}
int64_t synced_time = 0;
if (preferences_opt_int64(preferences, "syncTime", &synced_time)) {
LOG_I(TAG, "Restoring last known time to %ld", (long)synced_time);
timeval get_nvs_time;
get_nvs_time.tv_sec = synced_time;
settimeofday(&get_nvs_time, nullptr);
}
preferences_close(preferences);
}
static void onTimeSynced(timeval* tv) {
@@ -1,22 +1,22 @@
#ifdef ESP_PLATFORM
#include <Tactility/service/development/DevelopmentService.h>
#include <app/install.h>
#include <app/manager.h>
#include <Tactility/app/App.h>
#include <Tactility/app/AppRegistration.h>
#include <tactility/log.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/StringUtils.h>
#include <Tactility/file/File.h>
#include <Tactility/network/HttpdReq.h>
#include <Tactility/network/Url.h>
#include <Tactility/Paths.h>
#include <Tactility/service/development/DevelopmentSettings.h>
#include <Tactility/service/ServiceRegistration.h>
#include <Tactility/StringUtils.h>
#include <Tactility/service/development/DevelopmentService.h>
#include <Tactility/service/development/DevelopmentSettings.h>
#include <ranges>
#include <sstream>
#include <tactility/log.h>
namespace tt::service::development {
extern const ServiceManifest manifest;
@@ -101,12 +101,19 @@ esp_err_t DevelopmentService::handleAppRun(httpd_req_t* request) {
return ESP_FAIL;
}
const auto& app_id = id_key_pos->second;
if (app::isRunning(app_id)) {
app::stopAll(app_id);
char app_id[32];
AppInstanceId instance_id;
// Warning: possible app closure between getting app id and instance id
if (
app_manager_get_topmost_app_id(app_id, sizeof(app_id)) == ERROR_NONE &&
app_manager_get_topmost_instance_id(&instance_id) == ERROR_NONE
) {
if (strcmp(id_key_pos->second.c_str(), app_id) == 0) {
app_manager_stop(instance_id);
}
}
app::start(app_id);
app_manager_start(id_key_pos->second.c_str(), &instance_id);
LOG_I(TAG, "[200] /app/run %s", id_key_pos->second.c_str());
httpd_resp_send(request, nullptr, 0);
@@ -186,7 +193,7 @@ esp_err_t DevelopmentService::handleAppInstall(httpd_req_t* request) {
LOG_W(TAG, "We have more bytes at the end of the request parsing?!");
}
if (!app::install(file_path)) {
if (app_install(file_path.c_str()) != ERROR_NONE) {
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to install");
return ESP_FAIL;
}
@@ -218,13 +225,13 @@ esp_err_t DevelopmentService::handleAppUninstall(httpd_req_t* request) {
return ESP_FAIL;
}
if (!app::findAppManifestById(id_key_pos->second)) {
if (!app_manager_find_manifest(id_key_pos->second.c_str())) {
LOG_I(TAG, "[200] /app/uninstall %s (app wasn't installed)", id_key_pos->second.c_str());
httpd_resp_send(request, nullptr, 0);
return ESP_OK;
}
if (app::uninstall(id_key_pos->second)) {
if (app_uninstall(id_key_pos->second.c_str()) == ERROR_NONE) {
LOG_I(TAG, "[200] /app/uninstall %s", id_key_pos->second.c_str());
httpd_resp_send(request, nullptr, 0);
return ESP_OK;
@@ -1,7 +1,7 @@
#ifdef ESP_PLATFORM
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/Paths.h>
#include <Tactility/service/development/DevelopmentSettings.h>
#include <map>
#include <string>
-375
View File
@@ -1,375 +0,0 @@
#include <Tactility/service/gui/GuiService.h>
#include "lvgl/devices/keyboard.h"
#include <Tactility/LogMessages.h>
#include <Tactility/Tactility.h>
#include <Tactility/app/AppInstance.h>
#include <Tactility/lvgl/Statusbar.h>
#include <Tactility/lvgl/UsbHidInput.h>
#include <Tactility/service/ServiceRegistration.h>
#include <Tactility/service/loader/Loader.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <cstring>
namespace tt::service::gui {
extern const ServiceManifest manifest;
constexpr auto* TAG = "GuiService";
using namespace loader;
constexpr auto* GUI_TASK_NAME = "gui";
void warnIfRunningOnGuiTask(const char* context) {
const char* task_name = pcTaskGetName(nullptr);
if (strcmp(GUI_TASK_NAME, task_name) == 0) {
LOG_W(TAG, "%s shouldn't run on the GUI task", context);
}
}
namespace {
enum class GuiDispatchType { Show, Hide, Exit };
struct GuiDispatchItem {
GuiService* service;
GuiDispatchType type;
std::shared_ptr<app::AppInstance> appInstance; // only used for Show
};
} // namespace
// region AppManifest
void GuiService::onGuiDispatch(void* context) {
std::unique_ptr<GuiDispatchItem> item(static_cast<GuiDispatchItem*>(context));
switch (item->type) {
case GuiDispatchType::Show:
item->service->showApp(item->appInstance);
break;
case GuiDispatchType::Hide:
item->service->hideApp();
break;
case GuiDispatchType::Exit:
item->service->exitRequested = true;
break;
}
}
void GuiService::onLoaderEvent(LoaderService::Event event) {
GuiDispatchItem* item;
if (event == LoaderService::Event::ApplicationShowing) {
auto app_instance = std::static_pointer_cast<app::AppInstance>(app::getCurrentAppContext());
item = new GuiDispatchItem{this, GuiDispatchType::Show, app_instance};
} else if (event == LoaderService::Event::ApplicationHiding) {
// hideDoneSem is a binary semaphore signaled by every hideApp() completion,
// including the one showApp() triggers internally (GuiDispatchType::Show, when an
// app is already being shown) - that release has no waiter and leaves a stale
// permit sitting available. Drain it before dispatching, or the acquire() below
// could consume that leftover permit instead of the one this specific Hide
// dispatch is about to produce, letting Destroyed run before the real onHide()
// for this app has finished.
hideDoneSem.acquire(0);
item = new GuiDispatchItem{this, GuiDispatchType::Hide, nullptr};
} else {
return;
}
if (dispatcher_dispatch(dispatcher, item, onGuiDispatch) != ERROR_NONE) {
LOG_E(TAG, "Failed to dispatch gui event");
delete item;
return;
}
if (event == LoaderService::Event::ApplicationHiding) {
// Block here (still on the Loader thread, inside publish()'s synchronous
// subscriber call) until hideApp() has actually run to completion on the GUI
// task. LoaderService::transitionAppToState(Hiding) must not return - and
// therefore the Destroyed transition right after it, which unloads an ELF app's
// code, must not run - until App::onHide() has fully finished. Bounded so a stuck
// GUI task can't wedge app shutdown forever.
if (!hideDoneSem.acquire(pdMS_TO_TICKS(5000))) {
LOG_E(TAG, "Timed out waiting for hideApp() to complete");
}
}
}
int32_t GuiService::guiMain() {
auto service = findServiceById<GuiService>(manifest.id);
if (!lvgl_try_lock(5000)) {
LOG_E(TAG, "LVGL guiMain start failed as LVGL couldn't be locked");
return 0;
}
// The screen root is created in the main task instead of during onStart because
// it allows onStart() to succeed faster and allows widget creation to happen in the background
auto* screen_root = lv_screen_active();
if (screen_root == nullptr) {
LOG_E(TAG, "No display found, exiting GUI task");
lvgl_unlock();
return 0;
}
lv_obj_set_style_border_width(screen_root, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_all(screen_root, 0, LV_STATE_DEFAULT);
lv_obj_t* vertical_container = lv_obj_create(screen_root);
lv_obj_set_size(vertical_container, LV_PCT(100), LV_PCT(100));
lv_obj_set_flex_flow(vertical_container, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(vertical_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_pad_gap(vertical_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_bg_color(vertical_container, lv_color_black(), LV_STATE_DEFAULT);
lv_obj_set_style_border_width(vertical_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_radius(vertical_container, 0, LV_STATE_DEFAULT);
service->statusbarWidget = lvgl::statusbar_create(vertical_container);
auto* app_container = lv_obj_create(vertical_container);
lv_obj_set_style_pad_all(app_container, 0, LV_STATE_DEFAULT);
lv_obj_set_style_border_width(app_container, 0, LV_STATE_DEFAULT);
lv_obj_set_width(app_container, LV_PCT(100));
lv_obj_set_flex_grow(app_container, 1);
lv_obj_set_flex_flow(app_container, LV_FLEX_FLOW_COLUMN);
service->appRootWidget = app_container;
lvgl_unlock();
while (!service->exitRequested) {
dispatcher_consume(service->dispatcher);
}
service->appRootWidget = nullptr;
service->statusbarWidget = nullptr;
return 0;
}
lv_obj_t* GuiService::createAppViews(lv_obj_t* parent) {
lv_obj_send_event(statusbarWidget, LV_EVENT_DRAW_MAIN, nullptr);
lv_obj_t* child_container = lv_obj_create(parent);
lv_obj_set_style_pad_all(child_container, 0, LV_STATE_DEFAULT);
lv_obj_set_width(child_container, LV_PCT(100));
lv_obj_set_style_border_width(child_container, 0, LV_STATE_DEFAULT);
lv_obj_set_flex_grow(child_container, 1);
if (lvgl_software_keyboard_is_enabled()) {
lvgl_software_keyboard_construct(&software_keyboard, parent);
} else {
software_keyboard = {
nullptr
};
}
return child_container;
}
void GuiService::redraw() {
// Lock GUI and LVGL
lock();
if (appRootWidget == nullptr) {
LOG_W(TAG, "No root widget");
unlock();
return;
}
bool lvgl_locked = false;
while (lvgl_is_running() && !(lvgl_locked = lvgl_try_lock(1000))) {
LOG_W(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "GuiService LVGL");
}
if (!lvgl_locked) {
unlock();
return;
}
if (!lvgl_is_running()) {
lvgl_unlock();
unlock();
return;
}
lv_obj_clean(appRootWidget);
if (appToRender != nullptr) {
// Create a default group which adds all objects automatically,
// and assign all indevs to it.
// This enables navigation with limited input, such as encoder wheels.
// The previous default group (if any) is no longer referenced by anything
// after lv_obj_clean() above, so it must be freed here or it leaks.
auto* previous_group = lv_group_get_default();
if (previous_group != nullptr) {
lv_group_delete(previous_group);
}
lv_group_t* group = lv_group_create();
auto* indev = lv_indev_get_next(nullptr);
while (indev) {
lv_indev_set_group(indev, group);
indev = lv_indev_get_next(indev);
}
lv_group_set_default(group);
app::Flags flags = std::static_pointer_cast<app::AppInstance>(appToRender)->getFlags();
if (flags.hideStatusbar) {
lv_obj_add_flag(statusbarWidget, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_remove_flag(statusbarWidget, LV_OBJ_FLAG_HIDDEN);
}
lv_obj_t* container = createAppViews(appRootWidget);
appToRender->getApp()->onShow(*appToRender, container);
} else {
LOG_W(TAG, "Nothing to draw");
}
lvgl_unlock();
unlock();
}
bool GuiService::onStart(ServiceContext& service) {
exitRequested = false;
dispatcher = dispatcher_alloc();
thread = new Thread(
GUI_TASK_NAME,
4096, // Last known minimum was 2800 for launching desktop
guiMain
);
thread->setPriority(THREAD_PRIORITY_SERVICE);
const auto loader = findLoaderService();
assert(loader != nullptr);
loader_pubsub_subscription = loader->getPubsub()->subscribe([this](auto event) {
onLoaderEvent(event);
});
isStarted = true;
lvgl::startUsbHidInput();
thread->start();
return true;
}
void GuiService::onStop(ServiceContext& service) {
lvgl::stopUsbHidInput();
lock();
const auto loader = findLoaderService();
assert(loader != nullptr);
loader->getPubsub()->unsubscribe(loader_pubsub_subscription);
appToRender = nullptr;
isStarted = false;
unlock();
auto* exit_item = new GuiDispatchItem{this, GuiDispatchType::Exit, nullptr};
if (dispatcher_dispatch(dispatcher, exit_item, onGuiDispatch) != ERROR_NONE) {
LOG_E(TAG, "Failed to dispatch gui exit event");
check(false, "Failed to dispatch exit signal to thread.");
delete exit_item;
}
thread->join();
lvgl_lock();
if (software_keyboard.object != nullptr) {
lvgl_software_keyboard_destruct(&software_keyboard);
}
auto* default_group = lv_group_get_default();
if (default_group != nullptr) {
lv_group_delete(default_group);
lv_group_set_default(nullptr);
}
auto* screen_root = lv_screen_active();
if (screen_root != nullptr) {
lv_obj_clean(screen_root);
}
lvgl_unlock();
delete thread;
dispatcher_free(dispatcher);
dispatcher = nullptr;
}
void GuiService::showApp(std::shared_ptr<app::AppInstance> app) {
auto lock = mutex.asScopedLock();
lock.lock();
if (!isStarted) {
LOG_E(TAG, "Failed to show app %s: GUI not started", app->getManifest().appId.c_str());
return;
}
if (appToRender != nullptr && appToRender->getLaunchId() == app->getLaunchId()) {
LOG_W(TAG, "Already showing %s", app->getManifest().appId.c_str());
return;
}
LOG_I(TAG, "Showing %s", app->getManifest().appId.c_str());
// Ensure previous app triggers onHide() logic
if (appToRender != nullptr) {
hideApp();
}
appToRender = std::move(app);
redraw();
}
void GuiService::hideApp() {
// Signals hideDoneSem on every return path (including the early-return guards below) -
// onLoaderEvent() blocks on this to know App::onHide() has actually finished before
// Loader proceeds to destroy the app (see hideDoneSem's declaration for why).
struct SignalOnExit {
Semaphore& sem;
~SignalOnExit() { sem.release(); }
} signal_on_exit { hideDoneSem };
auto lock = mutex.asScopedLock();
lock.lock();
if (!isStarted) {
LOG_E(TAG, "Failed to hide app: GUI not started");
return;
}
if (appToRender == nullptr) {
LOG_W(TAG, "hideApp() called but no app is currently shown");
return;
}
// We must lock the LVGL port, because the viewport hide callbacks
// might call LVGL APIs (e.g. to remove the keyboard from the screen root)
lvgl_lock();
appToRender->getApp()->onHide(*appToRender);
lvgl_unlock();
appToRender = nullptr;
}
std::shared_ptr<GuiService> findService() {
return std::static_pointer_cast<GuiService>(
findServiceById(manifest.id)
);
}
extern const ServiceManifest manifest = {
.id = "Gui",
.createService = create<GuiService>
};
// endregion
} // namespace
-328
View File
@@ -1,328 +0,0 @@
#include <Tactility/service/loader/Loader.h>
#include <Tactility/app/AppInstance.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/AppRegistration.h>
#include <Tactility/LogMessages.h>
#include <Tactility/DispatcherThread.h>
#include <Tactility/service/ServiceManifest.h>
#include <Tactility/service/ServiceRegistration.h>
#include <vector>
#include <tactility/log.h>
#include <tactility/memory.h>
namespace tt::service::loader {
constexpr auto* TAG = "Loader";
constexpr auto LOADER_TIMEOUT = (100 / portTICK_PERIOD_MS);
// Forward declaration
extern const ServiceManifest manifest;
static const char* appStateToString(app::State state) {
switch (state) {
using enum app::State;
case Initial:
return "initial";
case Created:
return "started";
case Showing:
return "showing";
case Hiding:
return "hiding";
case Destroyed:
return "stopped";
default:
return "?";
}
}
void LoaderService::onStartAppMessage(const std::string& id, app::LaunchId launchId, std::shared_ptr<const Bundle> parameters) {
LOG_I(TAG, "Start by id %s", id.c_str());
auto app_manifest = app::findAppManifestById(id);
if (app_manifest == nullptr) {
LOG_E(TAG, "App not found: %s", id.c_str());
return;
}
auto lock = mutex.asScopedLock();
if (!lock.lock(LOADER_TIMEOUT)) {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
return;
}
auto previous_app = !appStack.empty() ? appStack[appStack.size() - 1]: nullptr;
auto new_app = std::make_shared<app::AppInstance>(app_manifest, launchId, parameters);
new_app->mutableFlags().hideStatusbar = (app_manifest->appFlags & app::AppManifest::Flags::HideStatusBar);
// We might have to hide the previous app first
if (previous_app != nullptr) {
transitionAppToState(previous_app, app::State::Hiding);
}
appStack.push_back(new_app);
transitionAppToState(new_app, app::State::Created);
transitionAppToState(new_app, app::State::Showing);
memory_print_stats();
}
void LoaderService::onStopTopAppMessage(const std::string& id) {
auto lock = mutex.asScopedLock();
if (!lock.lock(LOADER_TIMEOUT)) {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
return;
}
size_t original_stack_size = appStack.size();
if (original_stack_size == 0) {
LOG_E(TAG, "Stop app: no app running");
return;
}
// Stop current app
auto app_to_stop = appStack[appStack.size() - 1];
if (app_to_stop->getManifest().appId != id) {
LOG_E(TAG, "Stop app: id mismatch (wanted %s but found %s on top of stack)", id.c_str(), app_to_stop->getManifest().appId.c_str());
return;
}
if (original_stack_size == 1 && app_to_stop->getManifest().appName != "Boot") {
LOG_E(TAG, "Stop app: can't stop root app");
return;
}
bool result_set = false;
app::Result result;
std::unique_ptr<Bundle> result_bundle;
if (app_to_stop->getApp()->moveResult(result, result_bundle)) {
result_set = true;
}
auto app_to_stop_launch_id = app_to_stop->getLaunchId();
transitionAppToState(app_to_stop, app::State::Hiding);
transitionAppToState(app_to_stop, app::State::Destroyed);
appStack.pop_back();
// We only expect the app to be referenced within the current scope
if (app_to_stop.use_count() > 1) {
LOG_W(TAG, "Memory leak: Stopped %s, but use count is %d", app_to_stop->getManifest().appId.c_str(), (int)(app_to_stop.use_count() - 1));
}
// Refcount is expected to be 2: 1 within app_to_stop and 1 within the current scope
if (app_to_stop->getApp().use_count() > 2) {
LOG_W(TAG, "Memory leak: Stopped %s, but use count is %d", app_to_stop->getManifest().appId.c_str(), (int)(app_to_stop->getApp().use_count() - 2));
}
std::shared_ptr<app::AppInstance> instance_to_resume;
// If there's a previous app, resume it
if (!appStack.empty()) {
instance_to_resume = appStack[appStack.size() - 1];
assert(instance_to_resume);
transitionAppToState(instance_to_resume, app::State::Showing);
}
// Unlock so that we can send results to app and they can also start/stop new apps while processing these results
lock.unlock();
// WARNING: After this point we cannot change the app states from this method directly anymore as we don't have a lock!
if (instance_to_resume != nullptr) {
if (result_set) {
if (result_bundle != nullptr) {
instance_to_resume->getApp()->onResult(
*instance_to_resume,
app_to_stop_launch_id,
result,
std::move(result_bundle)
);
} else {
instance_to_resume->getApp()->onResult(
*instance_to_resume,
app_to_stop_launch_id,
result,
nullptr
);
}
} else {
instance_to_resume->getApp()->onResult(
*instance_to_resume,
app_to_stop_launch_id,
app::Result::Cancelled,
nullptr
);
}
}
memory_print_stats();
}
int LoaderService::findAppInStack(const std::string& id) const {
auto lock = mutex.asScopedLock();
lock.lock();
for (size_t i = 0; i < appStack.size(); i++) {
if (appStack[i]->getManifest().appId == id) {
return i;
}
}
return -1;
}
void LoaderService::onStopAllAppMessage(const std::string& id) {
auto lock = mutex.asScopedLock();
if (!lock.lock(LOADER_TIMEOUT)) {
LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED);
return;
}
if (!isRunning(id)) {
LOG_E(TAG, "Stop all: %s not running", id.c_str());
return;
}
int app_to_stop_index = findAppInStack(id);
if (app_to_stop_index < 0) {
LOG_E(TAG, "Stop all: %s not found in stack", id.c_str());
return;
}
// Find an app to resume, if any
std::shared_ptr<app::AppInstance> instance_to_resume;
if (app_to_stop_index > 0) {
instance_to_resume = appStack[app_to_stop_index - 1];
assert(instance_to_resume);
}
// Stop all apps and find the LaunchId of the last-closed app, so we can call onResult() if needed
app::LaunchId last_launch_id = 0;
for (int i = appStack.size() - 1; i >= app_to_stop_index; i--) {
auto app_to_stop = appStack[i];
// Hide the app first in case it's still being shown
if (app_to_stop->getState() == app::State::Showing) {
transitionAppToState(app_to_stop, app::State::Hiding);
}
transitionAppToState(app_to_stop, app::State::Destroyed);
last_launch_id = app_to_stop->getLaunchId();
appStack.pop_back();
}
if (instance_to_resume != nullptr) {
LOG_I(TAG, "Resuming %s", instance_to_resume->getManifest().appId.c_str());
transitionAppToState(instance_to_resume, app::State::Showing);
instance_to_resume->getApp()->onResult(
*instance_to_resume,
last_launch_id,
app::Result::Cancelled,
nullptr
);
}
}
void LoaderService::transitionAppToState(const std::shared_ptr<app::AppInstance>& app, app::State state) {
const app::AppManifest& app_manifest = app->getManifest();
const app::State old_state = app->getState();
LOG_I(TAG, "App \"%s\" state: %s -> %s",
app_manifest.appId.c_str(),
appStateToString(old_state),
appStateToString(state)
);
switch (state) {
using enum app::State;
case Initial:
check(false, LOG_MESSAGE_ILLEGAL_STATE);
case Created:
assert(app->getState() == app::State::Initial);
app->getApp()->onCreate(*app);
pubsubExternal->publish(Event::ApplicationStarted);
break;
case Showing: {
assert(app->getState() == app::State::Hiding || app->getState() == app::State::Created);
pubsubExternal->publish(Event::ApplicationShowing);
break;
}
case Hiding: {
assert(app->getState() == app::State::Showing);
pubsubExternal->publish(Event::ApplicationHiding);
break;
}
case Destroyed:
app->getApp()->onDestroy(*app);
pubsubExternal->publish(Event::ApplicationStopped);
break;
}
app->setState(state);
}
app::LaunchId LoaderService::start(const std::string& id, std::shared_ptr<const Bundle> parameters) {
const auto launch_id = nextLaunchId++;
dispatcherThread->dispatch([this, id, launch_id, parameters]() {
onStartAppMessage(id, launch_id, parameters);
});
return launch_id;
}
void LoaderService::stopTop() {
const auto& id = getCurrentAppContext()->getManifest().appId;
stopTop(id);
}
void LoaderService::stopTop(const std::string& id) {
LOG_I(TAG, "dispatching stopTop(%s)", id.c_str());
dispatcherThread->dispatch([this, id] {
onStopTopAppMessage(id);
});
}
void LoaderService::stopAll(const std::string& id) {
LOG_I(TAG, "dispatching stopAll(%s)", id.c_str());
dispatcherThread->dispatch([this, id] {
onStopAllAppMessage(id);
});
}
std::shared_ptr<app::AppContext> LoaderService::getCurrentAppContext() {
const auto lock = mutex.asScopedLock();
lock.lock();
if (appStack.empty()) {
return nullptr;
} else {
return appStack[appStack.size() - 1];
}
}
bool LoaderService::isRunning(const std::string& id) const {
const auto lock = mutex.asScopedLock();
lock.lock();
for (const auto& app : appStack) {
if (app->getManifest().appId == id) {
return true;
}
}
return false;
}
std::shared_ptr<LoaderService> findLoaderService() {
return service::findServiceById<LoaderService>(manifest.id);
}
extern const ServiceManifest manifest = {
.id = "Loader",
.createService = create<LoaderService>
};
} // namespace
@@ -120,7 +120,7 @@ bool RtcTimeService::onStart(ServiceContext& serviceContext) {
system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0);
}
if (system_event_subscribe(KERNEL_EVENT_TIME_CHANGED, &RtcTimeService::onTimeChangedTrampoline, this) == ERROR_NONE) {
if (system_event_callback_add(KERNEL_EVENT_TIME_CHANGED, &RtcTimeService::onTimeChangedTrampoline, this) == ERROR_NONE) {
timeEventSubscribed = true;
}
@@ -129,7 +129,7 @@ bool RtcTimeService::onStart(ServiceContext& serviceContext) {
void RtcTimeService::onStop(ServiceContext& serviceContext) {
if (timeEventSubscribed) {
system_event_unsubscribe(KERNEL_EVENT_TIME_CHANGED, &RtcTimeService::onTimeChangedTrampoline);
system_event_callback_remove(KERNEL_EVENT_TIME_CHANGED, &RtcTimeService::onTimeChangedTrampoline);
timeEventSubscribed = false;
}
@@ -5,9 +5,10 @@
#include <Tactility/LogMessages.h>
#include <Tactility/CpuAffinity.h>
#include <Tactility/TactilityCore.h>
#include <Tactility/service/loader/Loader.h>
#include <Tactility/service/screenshot/ScreenshotTask.h>
#include <app/manager.h>
#include <tactility/delay.h>
#include <tactility/log.h>
@@ -66,7 +67,7 @@ static void makeScreenshot(const std::string& filename) {
void ScreenshotTask::taskMain() {
uint8_t screenshots_taken = 0;
std::string last_app_id;
uint32_t last_app_instance_id = 0;
while (!isInterrupted()) {
if (work.type == TASK_WORK_TYPE_DELAY) {
@@ -85,15 +86,13 @@ void ScreenshotTask::taskMain() {
}
}
} else if (work.type == TASK_WORK_TYPE_APPS) {
auto appContext = app::getCurrentAppContext();
if (appContext != nullptr) {
const app::AppManifest& manifest = appContext->getManifest();
if (manifest.appId != last_app_id) {
delay_millis(100);
last_app_id = manifest.appId;
auto filename = std::format("{}/screenshot-{}.png", work.path, manifest.appId);
makeScreenshot(filename);
}
AppInstanceId app_instance_id = 0;
bool has_topmost = app_manager_get_topmost_instance_id(&app_instance_id) == ERROR_NONE;
if (has_topmost && app_instance_id != last_app_instance_id) {
delay_millis(100);
last_app_instance_id = app_instance_id;
auto filename = std::format("{}/screenshot-{}.png", work.path, app_instance_id);
makeScreenshot(filename);
}
// Ensure the LVGL widgets are rendered as the app just started
delay_millis(250);
@@ -8,19 +8,15 @@
#include <Tactility/lvgl/Statusbar.h>
#include <Tactility/Mutex.h>
#include <tactility/check.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/StringUtils.h>
#include <Tactility/TactilityConfig.h>
#include <Tactility/app/AppRegistration.h>
#include <Tactility/app/AppManifest.h>
#include <Tactility/app/App.h>
#include <Tactility/service/wifi/Wifi.h>
#include <Tactility/lvgl/Lvgl.h>
#include <Tactility/network/HttpdReq.h>
#include <Tactility/network/Url.h>
#include <Tactility/Paths.h>
#include <Tactility/lvgl/Lvgl.h>
#include <Tactility/StringUtils.h>
#include <Tactility/service/wifi/Wifi.h>
#include <tactility/check.h>
#include <tactility/filesystem/file_system.h>
#include <tactility/log.h>
@@ -31,6 +27,10 @@
#include <lv_screenshot.h>
#endif
#include "app/install.h"
#include "app/manager.h"
#include <atomic>
#include <cctype>
#include <cerrno>
@@ -41,8 +41,8 @@
#include <esp_netif.h>
#include <esp_system.h>
#include <esp_vfs_fat.h>
#include <esp_wifi_default.h>
#include <esp_wifi.h>
#include <esp_wifi_default.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <iomanip>
@@ -50,6 +50,7 @@
#include <mbedtls/base64.h>
#include <ranges>
#include <sstream>
#include <vector>
namespace tt::service::webserver {
@@ -1215,32 +1216,30 @@ esp_err_t WebServerService::handleApiSysinfo(httpd_req_t* request) {
esp_err_t WebServerService::handleApiApps(httpd_req_t* request) {
LOG_I(TAG, "GET /api/apps");
auto manifests = app::getAppManifests();
std::vector<const ::AppManifest*> manifests;
app_manager_for_each_manifest([](const ::AppManifest* manifest, void* context) {
static_cast<std::vector<const ::AppManifest*>*>(context)->push_back(manifest);
}, &manifests);
std::ostringstream json;
json << "{\"apps\":[";
bool first = true;
for (const auto& manifest : manifests) {
for (const auto* manifest : manifests) {
if (!first) json << ",";
first = false;
json << "{";
json << "\"id\":\"" << escapeJson(manifest->appId) << "\",";
json << "\"name\":\"" << escapeJson(manifest->appName) << "\",";
json << "\"version\":\"" << escapeJson(manifest->appVersionName) << "\",";
json << "\"id\":\"" << escapeJson(manifest->id) << "\",";
json << "\"name\":\"" << escapeJson(manifest->name) << "\",";
const char* category = "user";
if (manifest->appCategory == app::Category::System) category = "system";
else if (manifest->appCategory == app::Category::Settings) category = "settings";
if (manifest->category == APP_CATEGORY_SYSTEM) category = "system";
else if (manifest->category == APP_CATEGORY_SETTINGS) category = "settings";
json << "\"category\":\"" << category << "\",";
json << "\"isExternal\":" << (manifest->appLocation.isExternal() ? "true" : "false") << ",";
json << "\"hidden\":" << ((manifest->appFlags & app::AppManifest::Flags::Hidden) ? "true" : "false");
if (!manifest->appIcon.empty()) {
json << ",\"icon\":\"" << escapeJson(manifest->appIcon) << "\"";
}
json << "\"isExternal\":" << (manifest->location.type == APP_LOCATION_PATH ? "true" : "false") << ",";
json << "\"hidden\":" << ((manifest->flags & APP_MANIFEST_FLAG_HIDDEN) ? "true" : "false");
json << "}";
}
@@ -1262,18 +1261,16 @@ esp_err_t WebServerService::handleApiAppsRun(httpd_req_t* request) {
return ESP_FAIL;
}
auto manifest = app::findAppManifestById(appId);
if (!manifest) {
auto* manifest = app_manager_find_manifest(appId.c_str());
if (manifest == nullptr) {
httpd_resp_send_err(request, HTTPD_404_NOT_FOUND, "app not found");
return ESP_FAIL;
}
// Stop if already running
if (app::isRunning(appId)) {
app::stopAll(appId);
}
app::start(appId);
// Every app instance gets its own task now, so there's no "stop the existing one first" -
// this just starts a fresh instance alongside whatever's already running.
AppInstanceId instance_id = 0;
app_manager_start(appId.c_str(), &instance_id);
LOG_I(TAG, "[200] /api/apps/run %s", appId.c_str());
httpd_resp_sendstr(request, "ok");
@@ -1290,20 +1287,20 @@ esp_err_t WebServerService::handleApiAppsUninstall(httpd_req_t* request) {
return ESP_FAIL;
}
auto manifest = app::findAppManifestById(appId);
if (!manifest) {
auto* manifest = app_manager_find_manifest(appId.c_str());
if (manifest == nullptr) {
LOG_I(TAG, "[200] /api/apps/uninstall %s (app wasn't installed)", appId.c_str());
httpd_resp_sendstr(request, "ok");
return ESP_OK;
}
// Only allow uninstalling external apps
if (manifest->appLocation.isInternal()) {
// Only allow uninstalling external (side-loaded) apps
if (manifest->location.type != APP_LOCATION_PATH) {
httpd_resp_send_err(request, HTTPD_403_FORBIDDEN, "cannot uninstall system apps");
return ESP_FAIL;
}
if (app::uninstall(appId)) {
if (app_uninstall(appId.c_str()) == ERROR_NONE) {
LOG_I(TAG, "[200] /api/apps/uninstall %s", appId.c_str());
httpd_resp_sendstr(request, "ok");
return ESP_OK;
@@ -1393,7 +1390,7 @@ esp_err_t WebServerService::handleApiAppsInstall(httpd_req_t* request) {
}
// Install the app
if (!app::install(file_path)) {
if (app_install(file_path.c_str()) != ERROR_NONE) {
file::deleteFile(file_path);
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, "installation failed");
return ESP_FAIL;
+2 -2
View File
@@ -510,7 +510,7 @@ public:
LOG_W(TAG, "No WiFi device found");
}
if (system_event_subscribe(KERNEL_EVENT_BOOT_COMPLETED, onBootCompleted, nullptr) == ERROR_NONE) {
if (system_event_callback_add(KERNEL_EVENT_BOOT_COMPLETED, onBootCompleted, nullptr) == ERROR_NONE) {
state.bootEventSubscribed = true;
}
@@ -531,7 +531,7 @@ public:
state.autoConnectTimer = nullptr; // Must release as it holds a reference via its callback.
if (state.bootEventSubscribed) {
system_event_unsubscribe(KERNEL_EVENT_BOOT_COMPLETED, onBootCompleted);
system_event_callback_remove(KERNEL_EVENT_BOOT_COMPLETED, onBootCompleted);
state.bootEventSubscribed = false;
}
@@ -138,20 +138,24 @@ bool contains(const std::string& ssid) {
bool load(const std::string& ssid, WifiApSettings& apSettings) {
auto service_context = findServiceContext();
if (service_context == nullptr) {
LOG_E(TAG, "No service context");
return false;
}
const auto file_path = getApPropertiesFilePath(service_context->getPaths(), ssid);
if (!file::isFile(file_path)) {
LOG_E(TAG, "Not a file: %s", file_path.c_str());
return false;
}
std::map<std::string, std::string> map;
if (!file::loadPropertiesFile(file_path, map)) {
LOG_E(TAG, "Failed to load properties from %s", file_path.c_str());
return false;
}
// SSID is required
if (!map.contains(AP_PROPERTIES_KEY_SSID)) {
LOG_E(TAG, "File does not contain SSID: %s", file_path.c_str());
return false;
}
@@ -166,6 +170,7 @@ bool load(const std::string& ssid, WifiApSettings& apSettings) {
} else if (decrypt(ssid, encrypted_password, password_decrypted)) {
apSettings.password = password_decrypted;
} else {
LOG_E(TAG, "Failed to decrypt password from %s", file_path.c_str());
return false;
}
} else {
@@ -9,7 +9,7 @@
#include <Tactility/file/File.h>
#include <Tactility/service/wifi/WifiApSettings.h>
#include <Tactility/Paths.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/Tactility.h>
#include <tactility/log.h>
+1 -1
View File
@@ -1,8 +1,8 @@
#include <Tactility/settings/AudioSettings.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/Paths.h>
#include <algorithm>
#include <cstdio>
+1 -1
View File
@@ -3,7 +3,7 @@
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/settings/BootSettings.h>
#include <Tactility/Paths.h>
#include <Tactility/DeprecatedPaths.h>
#include <format>
#include <string>
@@ -2,7 +2,7 @@
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/Paths.h>
#include <Tactility/DeprecatedPaths.h>
#include <map>
#include <string>
@@ -1,7 +1,7 @@
#include <Tactility/settings/KeyboardSettings.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/Paths.h>
#include <Tactility/DeprecatedPaths.h>
#include <map>
#include <string>
+1 -1
View File
@@ -5,7 +5,7 @@
#include <Tactility/settings/Language.h>
#include <Tactility/settings/SystemSettings.h>
#include "Tactility/Paths.h"
#include "Tactility/DeprecatedPaths.h"
#include <tactility/log.h>
@@ -2,7 +2,7 @@
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/Paths.h>
#include <Tactility/DeprecatedPaths.h>
#include <cstdlib>
#include <cerrno>
@@ -1,7 +1,7 @@
#include <Tactility/settings/TrackballSettings.h>
#include <Tactility/file/File.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/Paths.h>
#include <Tactility/DeprecatedPaths.h>
#include <map>
#include <string>
@@ -1,7 +1,7 @@
#include <Tactility/settings/WebServerSettings.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/DeprecatedPaths.h>
#include <Tactility/file/File.h>
#include <Tactility/Paths.h>
#include <Tactility/file/PropertiesFile.h>
#include <Tactility/settings/WebServerSettings.h>
#include <tactility/log.h>
+59 -20
View File
@@ -1,8 +1,9 @@
#include <Tactility/settings/Time.h>
#include <Tactility/Preferences.h>
#include <Tactility/settings/SystemSettings.h>
#include <tactility/paths.h>
#include <tactility/preferences.h>
#include <tactility/system_event.h>
#ifdef ESP_PLATFORM
@@ -17,6 +18,21 @@ constexpr auto* TIMEZONE_PREFERENCES_KEY_NAME = "tz_name";
constexpr auto* TIMEZONE_PREFERENCES_KEY_CODE = "tz_code";
constexpr auto* TIMEZONE_PREFERENCES_KEY_TIME24 = "tz_time24";
namespace {
// Same "time" namespace/file that Ntp.cpp's storeTimeInNvs()/setTimeFromNvs() use for
// "syncTime" - matches the shared NVS namespace this used to be.
bool getPreferencesPath(std::string& outPath) {
char root[128];
if (paths_get_user_data_path(root, sizeof(root)) != ERROR_NONE) {
return false;
}
outPath = std::string(root) + "/" + TIME_SETTINGS_NAMESPACE + ".properties";
return true;
}
} // namespace
void initTimeZone() {
#ifdef ESP_PLATFORM
auto code= getTimeZoneCode();
@@ -28,9 +44,15 @@ void initTimeZone() {
}
void setTimeZone(const std::string& name, const std::string& code) {
Preferences preferences(TIME_SETTINGS_NAMESPACE);
preferences.putString(TIMEZONE_PREFERENCES_KEY_NAME, name);
preferences.putString(TIMEZONE_PREFERENCES_KEY_CODE, code);
std::string path;
if (getPreferencesPath(path)) {
Preferences* preferences = preferences_open(path.c_str());
if (preferences != nullptr) {
preferences_put_string(preferences, TIMEZONE_PREFERENCES_KEY_NAME, name.c_str());
preferences_put_string(preferences, TIMEZONE_PREFERENCES_KEY_CODE, code.c_str());
preferences_close(preferences);
}
}
#ifdef ESP_PLATFORM
setenv("TZ", code.c_str(), 1);
@@ -41,32 +63,49 @@ void setTimeZone(const std::string& name, const std::string& code) {
}
std::string getTimeZoneName() {
Preferences preferences(TIME_SETTINGS_NAMESPACE);
std::string result;
if (preferences.optString(TIMEZONE_PREFERENCES_KEY_NAME, result)) {
return result;
} else {
return "Europe/Amsterdam";
std::string path;
if (getPreferencesPath(path)) {
Preferences* preferences = preferences_open(path.c_str());
if (preferences != nullptr) {
char buffer[64];
error_t error = preferences_opt_string(preferences, TIMEZONE_PREFERENCES_KEY_NAME, buffer, sizeof(buffer));
preferences_close(preferences);
if (error == ERROR_NONE) {
return buffer;
}
}
}
return "Europe/Amsterdam";
}
bool hasTimeZone() {
Preferences preferences(TIME_SETTINGS_NAMESPACE);
std::string timezone;
if (!preferences.optString(TIMEZONE_PREFERENCES_KEY_NAME, timezone)) {
std::string path;
if (!getPreferencesPath(path)) {
return false;
}
return !timezone.empty();
Preferences* preferences = preferences_open(path.c_str());
if (preferences == nullptr) {
return false;
}
bool has = preferences_has_string(preferences, TIMEZONE_PREFERENCES_KEY_NAME);
preferences_close(preferences);
return has;
}
std::string getTimeZoneCode() {
Preferences preferences(TIME_SETTINGS_NAMESPACE);
std::string result;
if (preferences.optString(TIMEZONE_PREFERENCES_KEY_CODE, result)) {
return result;
} else {
return "CET-1CEST,M3.5.0,M10.5.0/3"; // Default: Europe/Amsterdam
std::string path;
if (getPreferencesPath(path)) {
Preferences* preferences = preferences_open(path.c_str());
if (preferences != nullptr) {
char buffer[64];
error_t error = preferences_opt_string(preferences, TIMEZONE_PREFERENCES_KEY_CODE, buffer, sizeof(buffer));
preferences_close(preferences);
if (error == ERROR_NONE) {
return buffer;
}
}
}
return "CET-1CEST,M3.5.0,M10.5.0/3"; // Default: Europe/Amsterdam
}
bool isTimeFormat24Hour() {