From af5673059ec1d2f93b7412b0f37c66b21a4ee11d Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Tue, 20 Oct 2026 21:20:20 +0200 Subject: [PATCH 1/9] Add WiFi AP mode in base driver --- .../mock-drivers-module/source/mock_wifi.cpp | 75 ++++++++- .../source/drivers/esp32_wifi.cpp | 148 +++++++++++++++++- .../source/drivers/esp32_wifi_pinned.cpp | 35 ++++- Platforms/platform-esp32/source/module.cpp | 3 + .../include/tactility/drivers/wifi.h | 64 +++++++- TactilityKernel/source/drivers/wifi.cpp | 24 +++ TactilityKernel/source/symbols.c | 3 + TactilityKernel/tests/source/wifi_test.cpp | 121 ++++++++++++++ 8 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 TactilityKernel/tests/source/wifi_test.cpp diff --git a/Drivers/mock-drivers-module/source/mock_wifi.cpp b/Drivers/mock-drivers-module/source/mock_wifi.cpp index 81b693f64..a55cb62e7 100644 --- a/Drivers/mock-drivers-module/source/mock_wifi.cpp +++ b/Drivers/mock-drivers-module/source/mock_wifi.cpp @@ -37,6 +37,7 @@ constexpr MockApRecord MOCK_SCAN_RESULTS[] = { constexpr size_t MOCK_SCAN_RESULT_COUNT = sizeof(MOCK_SCAN_RESULTS) / sizeof(MOCK_SCAN_RESULTS[0]); constexpr int8_t MOCK_CONNECTED_RSSI = -30; constexpr const char* MOCK_IPV4_ADDRESS = "192.168.1.2"; +constexpr const char* MOCK_ACCESS_POINT_IPV4_ADDRESS = "192.168.4.1"; struct MockWifiCtx { Device* device = nullptr; @@ -44,6 +45,7 @@ struct MockWifiCtx { Mutex mutex {}; WifiRadioState radioState = WIFI_RADIO_STATE_OFF; WifiStationState stationState = WIFI_STATION_STATE_DISCONNECTED; + WifiAccessPointState accessPointState = WIFI_ACCESS_POINT_STATE_STOPPED; bool scanning = false; char targetSsid[33] = {}; @@ -91,8 +93,68 @@ error_t apiGetStationState(Device* device, WifiStationState* state) { error_t apiGetAccessPointState(Device* device, WifiAccessPointState* state) { auto* ctx = GET_CTX(device); if (ctx == nullptr || state == nullptr) return ERROR_INVALID_ARGUMENT; - // Access point mode isn't implemented by this mock. - *state = WIFI_ACCESS_POINT_STATE_STOPPED; + mutex_lock(&ctx->mutex); + *state = ctx->accessPointState; + mutex_unlock(&ctx->mutex); + return ERROR_NONE; +} + +void setAccessPointState(MockWifiCtx* ctx, WifiAccessPointState state) { + mutex_lock(&ctx->mutex); + ctx->accessPointState = state; + mutex_unlock(&ctx->mutex); + + WifiEvent event = {}; + event.type = WIFI_EVENT_TYPE_ACCESS_POINT_STATE_CHANGED; + event.access_point_state = state; + fireEvent(ctx, event); +} + +error_t apiAccessPointStart(Device* device, const WifiAccessPointConfig* config) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return ERROR_INVALID_STATE; + if (config == nullptr) return ERROR_INVALID_ARGUMENT; + + const size_t ssidLength = strnlen(config->ssid, sizeof(config->ssid)); + const size_t passwordLength = strnlen(config->password, sizeof(config->password)); + const bool ssidValid = ssidLength > 0 && ssidLength < sizeof(config->ssid); + const bool passwordValid = passwordLength == 0 || (passwordLength >= 8 && passwordLength < sizeof(config->password)); + if (!ssidValid || !passwordValid || config->channel > 13) { + return ERROR_INVALID_ARGUMENT; + } + + mutex_lock(&ctx->mutex); + const bool started = ctx->accessPointState == WIFI_ACCESS_POINT_STATE_STARTED; + mutex_unlock(&ctx->mutex); + if (started) return ERROR_INVALID_STATE; + + setAccessPointState(ctx, WIFI_ACCESS_POINT_STATE_STARTED); + return ERROR_NONE; +} + +error_t apiAccessPointStop(Device* device) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return ERROR_INVALID_STATE; + + mutex_lock(&ctx->mutex); + const bool started = ctx->accessPointState == WIFI_ACCESS_POINT_STATE_STARTED; + mutex_unlock(&ctx->mutex); + if (started) { + setAccessPointState(ctx, WIFI_ACCESS_POINT_STATE_STOPPED); + } + return ERROR_NONE; +} + +error_t apiAccessPointGetIpv4Address(Device* device, char* ipv4) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || ipv4 == nullptr) return ERROR_INVALID_ARGUMENT; + + mutex_lock(&ctx->mutex); + const bool started = ctx->accessPointState == WIFI_ACCESS_POINT_STATE_STARTED; + mutex_unlock(&ctx->mutex); + if (!started) return ERROR_INVALID_STATE; + + strcpy(ipv4, MOCK_ACCESS_POINT_IPV4_ADDRESS); return ERROR_NONE; } @@ -354,7 +416,11 @@ const WifiApi mock_wifi_api = { .station_disconnect = apiStationDisconnect, .station_get_rssi = apiStationGetRssi, .event_subscribe = apiEventSubscribe, - .event_unsubscribe = apiEventUnsubscribe + .event_unsubscribe = apiEventUnsubscribe, + .get_firmware_ops = nullptr, + .access_point_start = apiAccessPointStart, + .access_point_stop = apiAccessPointStop, + .access_point_get_ipv4_address = apiAccessPointGetIpv4Address }; // ---- Driver lifecycle ---- @@ -378,6 +444,9 @@ error_t stopDevice(Device* device) { auto* ctx = GET_CTX(device); if (ctx == nullptr) return ERROR_NONE; + if (ctx->accessPointState == WIFI_ACCESS_POINT_STATE_STARTED) { + apiAccessPointStop(device); + } if (ctx->radioState == WIFI_RADIO_STATE_ON) { apiSetRadioOff(device); } diff --git a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp index cd1234e29..4a2bff6ff 100644 --- a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp +++ b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp @@ -45,6 +45,9 @@ struct Esp32WifiCtx { Mutex mutex{}; WifiRadioState radioState = WIFI_RADIO_STATE_OFF; WifiStationState stationState = WIFI_STATION_STATE_DISCONNECTED; + WifiAccessPointState accessPointState = WIFI_ACCESS_POINT_STATE_STOPPED; + // Exists while the access point runs + esp_netif_t* accessPointNetif = nullptr; bool scanning = false; char targetSsid[33] = {}; esp_netif_ip_info_t ipInfo{}; @@ -340,8 +343,9 @@ error_t api_get_station_state(Device* device, WifiStationState* state) { error_t api_get_access_point_state(Device* device, WifiAccessPointState* state) { auto* ctx = GET_CTX(device); if (ctx == nullptr || state == nullptr) return ERROR_INVALID_ARGUMENT; - // Access point mode isn't implemented by this driver. - *state = WIFI_ACCESS_POINT_STATE_STOPPED; + mutex_lock(&ctx->mutex); + *state = ctx->accessPointState; + mutex_unlock(&ctx->mutex); return ERROR_NONE; } @@ -505,6 +509,139 @@ error_t api_station_get_rssi(Device* device, int32_t* rssi) { return ERROR_NONE; } +constexpr auto* ACCESS_POINT_ADDRESS = "192.168.4.1"; +constexpr uint8_t ACCESS_POINT_DEFAULT_CHANNEL = 1; +constexpr uint8_t ACCESS_POINT_MAX_CHANNEL = 13; +constexpr uint8_t ACCESS_POINT_DEFAULT_MAX_CONNECTIONS = 4; + +// Detaches the netif from the internal WiFi event handlers before the radio is released, +// otherwise esp_netif_destroy() can race with esp_wifi_stop()'s own netif teardown. +void release_access_point(Esp32WifiCtx* ctx) { + if (ctx->accessPointNetif != nullptr) { + esp_wifi_clear_default_wifi_driver_and_handlers(ctx->accessPointNetif); + } + esp32_wifi_radio_release(WIFI_MODE_AP); + if (ctx->accessPointNetif != nullptr) { + esp_netif_destroy(ctx->accessPointNetif); + ctx->accessPointNetif = nullptr; + } +} + +void fire_access_point_event(Esp32WifiCtx* ctx, WifiAccessPointState state) { + WifiEvent event = {}; + event.type = WIFI_EVENT_TYPE_ACCESS_POINT_STATE_CHANGED; + event.access_point_state = state; + fire_event(ctx, event); +} + +error_t api_access_point_start(Device* device, const WifiAccessPointConfig* config) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return ERROR_INVALID_STATE; + if (config == nullptr) return ERROR_INVALID_ARGUMENT; + + const size_t ssid_length = strnlen(config->ssid, sizeof(config->ssid)); + const size_t password_length = strnlen(config->password, sizeof(config->password)); + const bool ssid_valid = ssid_length > 0 && ssid_length < sizeof(config->ssid); + const bool password_valid = password_length == 0 || (password_length >= 8 && password_length < sizeof(config->password)); + if (!ssid_valid || !password_valid || config->channel > ACCESS_POINT_MAX_CHANNEL) { + return ERROR_INVALID_ARGUMENT; + } + + mutex_lock(&ctx->mutex); + const bool started = ctx->accessPointState == WIFI_ACCESS_POINT_STATE_STARTED; + mutex_unlock(&ctx->mutex); + if (started) return ERROR_INVALID_STATE; + + // The netif and its address exist before the radio starts, so that the netif handles the start event of the access point + ctx->accessPointNetif = esp_netif_create_default_wifi_ap(); + if (ctx->accessPointNetif == nullptr) { + LOG_E(TAG, "esp_netif_create_default_wifi_ap() failed"); + return ERROR_RESOURCE; + } + + // A static address, so clients always find the access point at the same place + esp_netif_ip_info_t ip_info {}; + ip_info.ip.addr = ESP_IP4TOADDR(192, 168, 4, 1); + ip_info.gw.addr = ESP_IP4TOADDR(192, 168, 4, 1); + ip_info.netmask.addr = ESP_IP4TOADDR(255, 255, 255, 0); + + const esp_err_t stop_dhcp_result = esp_netif_dhcps_stop(ctx->accessPointNetif); + const esp_err_t set_ip_result = esp_netif_set_ip_info(ctx->accessPointNetif, &ip_info); + const esp_err_t start_dhcp_result = esp_netif_dhcps_start(ctx->accessPointNetif); + if ((stop_dhcp_result != ESP_OK && stop_dhcp_result != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED) || + set_ip_result != ESP_OK || + (start_dhcp_result != ESP_OK && start_dhcp_result != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED)) { + LOG_E(TAG, "Failed to set up the access point network"); + esp_netif_destroy(ctx->accessPointNetif); + ctx->accessPointNetif = nullptr; + return ERROR_RESOURCE; + } + + // Acquires the shared radio, which merges into WIFI_MODE_APSTA when the station owns the radio already + if (!esp32_wifi_radio_acquire(WIFI_MODE_AP)) { + LOG_E(TAG, "Failed to acquire the radio for the access point"); + esp_netif_destroy(ctx->accessPointNetif); + ctx->accessPointNetif = nullptr; + return ERROR_RESOURCE; + } + + wifi_config_t wifi_config {}; + strncpy(reinterpret_cast(wifi_config.ap.ssid), config->ssid, sizeof(wifi_config.ap.ssid) - 1); + wifi_config.ap.ssid_len = static_cast(ssid_length); + if (password_length > 0) { + wifi_config.ap.authmode = WIFI_AUTH_WPA2_PSK; + strncpy(reinterpret_cast(wifi_config.ap.password), config->password, sizeof(wifi_config.ap.password) - 1); + } else { + wifi_config.ap.authmode = WIFI_AUTH_OPEN; + } + wifi_config.ap.max_connection = config->max_connections != 0 ? config->max_connections : ACCESS_POINT_DEFAULT_MAX_CONNECTIONS; + wifi_config.ap.channel = config->channel != 0 ? config->channel : ACCESS_POINT_DEFAULT_CHANNEL; + + esp_err_t err = esp_wifi_set_config(WIFI_IF_AP, &wifi_config); + if (err != ESP_OK) { + LOG_E(TAG, "esp_wifi_set_config(AP) failed: %s", esp_err_to_name(err)); + release_access_point(ctx); + return ERROR_RESOURCE; + } + + mutex_lock(&ctx->mutex); + ctx->accessPointState = WIFI_ACCESS_POINT_STATE_STARTED; + mutex_unlock(&ctx->mutex); + fire_access_point_event(ctx, WIFI_ACCESS_POINT_STATE_STARTED); + + LOG_I(TAG, "Access point started: SSID '%s', channel %u, %s", config->ssid, static_cast(wifi_config.ap.channel), ACCESS_POINT_ADDRESS); + return ERROR_NONE; +} + +error_t api_access_point_stop(Device* device) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr) return ERROR_INVALID_STATE; + + mutex_lock(&ctx->mutex); + const bool started = ctx->accessPointState == WIFI_ACCESS_POINT_STATE_STARTED; + ctx->accessPointState = WIFI_ACCESS_POINT_STATE_STOPPED; + mutex_unlock(&ctx->mutex); + if (!started) return ERROR_NONE; + + release_access_point(ctx); + fire_access_point_event(ctx, WIFI_ACCESS_POINT_STATE_STOPPED); + LOG_I(TAG, "Access point stopped"); + return ERROR_NONE; +} + +error_t api_access_point_get_ipv4_address(Device* device, char* ipv4) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || ipv4 == nullptr) return ERROR_INVALID_ARGUMENT; + + mutex_lock(&ctx->mutex); + const bool started = ctx->accessPointState == WIFI_ACCESS_POINT_STATE_STARTED; + mutex_unlock(&ctx->mutex); + if (!started) return ERROR_INVALID_STATE; + + strcpy(ipv4, ACCESS_POINT_ADDRESS); + return ERROR_NONE; +} + error_t api_set_radio_on(Device* device) { auto* ctx = GET_CTX(device); if (ctx == nullptr) return ERROR_INVALID_STATE; @@ -614,7 +751,10 @@ const WifiApi esp32_wifi_api = { .station_get_rssi = api_station_get_rssi, .event_subscribe = api_event_subscribe, .event_unsubscribe = api_event_unsubscribe, - .get_firmware_ops = api_get_firmware_ops + .get_firmware_ops = api_get_firmware_ops, + .access_point_start = api_access_point_start, + .access_point_stop = api_access_point_stop, + .access_point_get_ipv4_address = api_access_point_get_ipv4_address }; error_t start_device(Device* device) { @@ -637,6 +777,8 @@ error_t stop_device(Device* device) { auto* ctx = GET_CTX(device); if (ctx == nullptr) return ERROR_NONE; + api_access_point_stop(device); + if (ctx->radioState == WIFI_RADIO_STATE_ON) { bring_down_wifi(ctx); } diff --git a/Platforms/platform-esp32/source/drivers/esp32_wifi_pinned.cpp b/Platforms/platform-esp32/source/drivers/esp32_wifi_pinned.cpp index c8ee8e2dd..32e0b466b 100644 --- a/Platforms/platform-esp32/source/drivers/esp32_wifi_pinned.cpp +++ b/Platforms/platform-esp32/source/drivers/esp32_wifi_pinned.cpp @@ -256,6 +256,36 @@ error_t api_station_disconnect(Device* device) { return err != ERROR_NONE ? err : result; } +error_t api_access_point_start(Device* device, const WifiAccessPointConfig* config) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || ctx->child == nullptr) return ERROR_INVALID_STATE; + + error_t result = ERROR_NONE; + Device* child = ctx->child; + error_t err = run_on_pinned_thread(ctx, [child, config, &result]() { + result = wifi_access_point_start(child, config); + }); + return err != ERROR_NONE ? err : result; +} + +error_t api_access_point_stop(Device* device) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || ctx->child == nullptr) return ERROR_INVALID_STATE; + + error_t result = ERROR_NONE; + Device* child = ctx->child; + error_t err = run_on_pinned_thread(ctx, [child, &result]() { + result = wifi_access_point_stop(child); + }); + return err != ERROR_NONE ? err : result; +} + +error_t api_access_point_get_ipv4_address(Device* device, char* ipv4) { + auto* ctx = GET_CTX(device); + if (ctx == nullptr || ctx->child == nullptr) return ERROR_INVALID_STATE; + return wifi_access_point_get_ipv4_address(ctx->child, ipv4); +} + const WifiApi esp32_wifi_pinned_api = { .set_radio_on = api_set_radio_on, .set_radio_off = api_set_radio_off, @@ -272,7 +302,10 @@ const WifiApi esp32_wifi_pinned_api = { .station_get_rssi = api_station_get_rssi, .event_subscribe = api_event_subscribe, .event_unsubscribe = api_event_unsubscribe, - .get_firmware_ops = nullptr + .get_firmware_ops = nullptr, + .access_point_start = api_access_point_start, + .access_point_stop = api_access_point_stop, + .access_point_get_ipv4_address = api_access_point_get_ipv4_address }; // ---- Driver lifecycle ---- diff --git a/Platforms/platform-esp32/source/module.cpp b/Platforms/platform-esp32/source/module.cpp index 26b0dcbab..eceaeb9c4 100644 --- a/Platforms/platform-esp32/source/module.cpp +++ b/Platforms/platform-esp32/source/module.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -154,6 +155,8 @@ static const ModuleSymbol platform_esp32_symbols[] = { // esp_err DEFINE_MODULE_SYMBOL(esp_err_to_name), DEFINE_MODULE_SYMBOL(_esp_error_check_failed), + // esp_mac + DEFINE_MODULE_SYMBOL(esp_read_mac), // esp random DEFINE_MODULE_SYMBOL(esp_random), DEFINE_MODULE_SYMBOL(esp_fill_random), diff --git a/TactilityKernel/include/tactility/drivers/wifi.h b/TactilityKernel/include/tactility/drivers/wifi.h index 114b4a494..58af1b921 100644 --- a/TactilityKernel/include/tactility/drivers/wifi.h +++ b/TactilityKernel/include/tactility/drivers/wifi.h @@ -62,7 +62,7 @@ enum WifiEventType { WIFI_EVENT_TYPE_RADIO_STATE_CHANGED, /** WifiStationState changed */ WIFI_EVENT_TYPE_STATION_STATE_CHANGED, - /** WifiAccessPointState changed */ + /** The result of a connection attempt of the station changed (see WifiStationConnectionError) */ WIFI_EVENT_TYPE_STATION_CONNECTION_RESULT, /** WifiAccessPointState changed */ WIFI_EVENT_TYPE_ACCESS_POINT_STATE_CHANGED, @@ -72,6 +72,18 @@ enum WifiEventType { WIFI_EVENT_TYPE_SCAN_FINISHED, }; +/** The settings of the access point of the device. */ +struct WifiAccessPointConfig { + /** Required. 1 to 32 bytes, null-terminated. */ + char ssid[33]; + /** An empty password makes an open network. Otherwise it needs 8 to 63 characters (WPA2-PSK). */ + char password[65]; + /** The Wi-Fi channel from 1 to 13. 0 means the default (1). */ + uint8_t channel; + /** The number of devices that can connect. 0 means the default (4). */ + uint8_t max_connections; +}; + enum WifiStationConnectionError { WIFI_STATION_CONNECTION_ERROR_NONE, /** Wrong password */ @@ -274,6 +286,38 @@ struct WifiApi { * co-processor (ops is left untouched in that case) */ error_t (*get_firmware_ops)(struct Device* device, const struct FirmwareOps** ops, void** ctx); + + /** + * Start the access point. It works independently of the radio and the station: the station may be on or off. + * Optional: NULL when the device has no access point mode. + * Fires WIFI_EVENT_TYPE_ACCESS_POINT_STATE_CHANGED. + * @param[in] device the wifi device + * @param[in] config the access point settings + * @retval ERROR_NONE on success + * @retval ERROR_INVALID_ARGUMENT the SSID is empty or too long, the password is not empty and not 8 to 63 characters, or the channel is above 13 + * @retval ERROR_INVALID_STATE the access point is started already. Stop it first + * @retval ERROR_RESOURCE the access point could not be started + */ + error_t (*access_point_start)(struct Device* device, const struct WifiAccessPointConfig* config); + + /** + * Stop the access point. + * Optional: NULL when the device has no access point mode. + * Fires WIFI_EVENT_TYPE_ACCESS_POINT_STATE_CHANGED. + * @param[in] device the wifi device + * @return ERROR_NONE on success, or if the access point is stopped already + */ + error_t (*access_point_stop)(struct Device* device); + + /** + * Get the IPv4 address of the device on its access point network. + * Optional: NULL when the device has no access point mode. + * @param[in] device the wifi device + * @param[out] ipv4 the buffer to store the IPv4 address (must be at least 16 bytes, will be null-terminated) + * @retval ERROR_NONE on success + * @retval ERROR_INVALID_STATE the access point is stopped + */ + error_t (*access_point_get_ipv4_address)(struct Device* device, char* ipv4); }; extern const struct DeviceType WIFI_TYPE; @@ -295,6 +339,24 @@ error_t wifi_station_connect(struct Device* device, const char* ssid, const char error_t wifi_station_disconnect(struct Device* device); error_t wifi_station_get_rssi(struct Device* device, int32_t* rssi); +/** + * Start the access point, with or without the station. See WifiApi::access_point_start(). + * @retval ERROR_NOT_SUPPORTED the device has no access point mode + */ +error_t wifi_access_point_start(struct Device* device, const struct WifiAccessPointConfig* config); + +/** + * Stop the access point. See WifiApi::access_point_stop(). + * @retval ERROR_NOT_SUPPORTED the device has no access point mode + */ +error_t wifi_access_point_stop(struct Device* device); + +/** + * Get the IPv4 address on the access point network. See WifiApi::access_point_get_ipv4_address(). + * @retval ERROR_NOT_SUPPORTED the device has no access point mode + */ +error_t wifi_access_point_get_ipv4_address(struct Device* device, char* ipv4); + /** * Register a subscription for @a device's WifiEvents. * @warning Does not work in ISR context. diff --git a/TactilityKernel/source/drivers/wifi.cpp b/TactilityKernel/source/drivers/wifi.cpp index 0a2e9b268..f7f8a6701 100644 --- a/TactilityKernel/source/drivers/wifi.cpp +++ b/TactilityKernel/source/drivers/wifi.cpp @@ -59,6 +59,30 @@ error_t wifi_station_get_rssi(struct Device* device, int32_t* rssi) { return WIFI_API(device)->station_get_rssi(device, rssi); } +error_t wifi_access_point_start(struct Device* device, const struct WifiAccessPointConfig* config) { + auto* api = WIFI_API(device); + if (api->access_point_start == nullptr) { + return ERROR_NOT_SUPPORTED; + } + return api->access_point_start(device, config); +} + +error_t wifi_access_point_stop(struct Device* device) { + auto* api = WIFI_API(device); + if (api->access_point_stop == nullptr) { + return ERROR_NOT_SUPPORTED; + } + return api->access_point_stop(device); +} + +error_t wifi_access_point_get_ipv4_address(struct Device* device, char* ipv4) { + auto* api = WIFI_API(device); + if (api->access_point_get_ipv4_address == nullptr) { + return ERROR_NOT_SUPPORTED; + } + return api->access_point_get_ipv4_address(device, ipv4); +} + error_t wifi_event_subscribe(struct Device* device, struct WifiEventSubscription* sub, struct TaskEventGroup* event_group) { mutex_construct(&sub->internal.ring_mutex); sub->internal.head = 0; diff --git a/TactilityKernel/source/symbols.c b/TactilityKernel/source/symbols.c index 2b20dab5b..e9e5e00e6 100644 --- a/TactilityKernel/source/symbols.c +++ b/TactilityKernel/source/symbols.c @@ -431,6 +431,9 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = { DEFINE_MODULE_SYMBOL(wifi_station_connect), DEFINE_MODULE_SYMBOL(wifi_station_disconnect), DEFINE_MODULE_SYMBOL(wifi_station_get_rssi), + DEFINE_MODULE_SYMBOL(wifi_access_point_start), + DEFINE_MODULE_SYMBOL(wifi_access_point_stop), + DEFINE_MODULE_SYMBOL(wifi_access_point_get_ipv4_address), DEFINE_MODULE_SYMBOL(wifi_event_subscribe), DEFINE_MODULE_SYMBOL(wifi_event_unsubscribe), DEFINE_MODULE_SYMBOL(wifi_event_poll), diff --git a/TactilityKernel/tests/source/wifi_test.cpp b/TactilityKernel/tests/source/wifi_test.cpp new file mode 100644 index 000000000..f2a30c075 --- /dev/null +++ b/TactilityKernel/tests/source/wifi_test.cpp @@ -0,0 +1,121 @@ +#include "doctest.h" +#include +#include +#include + +#include +#include + +static int access_point_start_calls = 0; +static int access_point_stop_calls = 0; +static WifiAccessPointConfig last_config {}; + +static error_t fake_access_point_start(Device*, const WifiAccessPointConfig* config) { + access_point_start_calls++; + last_config = *config; + return ERROR_NONE; +} + +static error_t fake_access_point_stop(Device*) { + access_point_stop_calls++; + return ERROR_INVALID_STATE; +} + +static error_t fake_access_point_get_ipv4_address(Device*, char* ipv4) { + strcpy(ipv4, "192.168.4.1"); + return ERROR_NONE; +} + +// A wifi driver without access point mode: the optional members are left out +static const WifiApi station_only_wifi_api = {}; + +static Driver station_only_wifi_driver = { + .name = "wifi_test_station_only_driver", + .compatible = (const char*[]) { "wifi_test,station_only", nullptr }, + .start_device = nullptr, + .stop_device = nullptr, + .api = &station_only_wifi_api, + .device_type = &WIFI_TYPE, + .internal = nullptr, +}; + +static const WifiApi access_point_wifi_api = { + .access_point_start = fake_access_point_start, + .access_point_stop = fake_access_point_stop, + .access_point_get_ipv4_address = fake_access_point_get_ipv4_address, +}; + +static Driver access_point_wifi_driver = { + .name = "wifi_test_access_point_driver", + .compatible = (const char*[]) { "wifi_test,access_point", nullptr }, + .start_device = nullptr, + .stop_device = nullptr, + .api = &access_point_wifi_api, + .device_type = &WIFI_TYPE, + .internal = nullptr, +}; + +TEST_CASE("wifi access point functions return ERROR_NOT_SUPPORTED for a driver without access point mode") { + static Device device { + .name = "wifi_test_station_only_device", + .config = nullptr, + .parent = nullptr, + }; + + CHECK_EQ(driver_construct_add(&station_only_wifi_driver), ERROR_NONE); + CHECK_EQ(device_construct_add(&device, "wifi_test,station_only"), ERROR_NONE); + CHECK_EQ(device_start(&device), ERROR_NONE); + + WifiAccessPointConfig config {}; + strcpy(config.ssid, "test"); + char ipv4[16] = {}; + CHECK_EQ(wifi_access_point_start(&device, &config), ERROR_NOT_SUPPORTED); + CHECK_EQ(wifi_access_point_stop(&device), ERROR_NOT_SUPPORTED); + CHECK_EQ(wifi_access_point_get_ipv4_address(&device, ipv4), ERROR_NOT_SUPPORTED); + + CHECK_EQ(device_stop(&device), ERROR_NONE); + CHECK_EQ(device_remove(&device), ERROR_NONE); + CHECK_EQ(device_destruct(&device), ERROR_NONE); + CHECK_EQ(driver_remove_destruct(&station_only_wifi_driver), ERROR_NONE); +} + +TEST_CASE("wifi access point functions call the driver and return its result") { + access_point_start_calls = 0; + access_point_stop_calls = 0; + last_config = {}; + + static Device device { + .name = "wifi_test_access_point_device", + .config = nullptr, + .parent = nullptr, + }; + + CHECK_EQ(driver_construct_add(&access_point_wifi_driver), ERROR_NONE); + CHECK_EQ(device_construct_add(&device, "wifi_test,access_point"), ERROR_NONE); + CHECK_EQ(device_start(&device), ERROR_NONE); + + WifiAccessPointConfig config {}; + strcpy(config.ssid, "Tactility-1234"); + strcpy(config.password, "secret-password"); + config.channel = 6; + config.max_connections = 2; + CHECK_EQ(wifi_access_point_start(&device, &config), ERROR_NONE); + CHECK_EQ(access_point_start_calls, 1); + CHECK_EQ(std::string(last_config.ssid), "Tactility-1234"); + CHECK_EQ(std::string(last_config.password), "secret-password"); + CHECK_EQ(last_config.channel, 6); + CHECK_EQ(last_config.max_connections, 2); + + char ipv4[16] = {}; + CHECK_EQ(wifi_access_point_get_ipv4_address(&device, ipv4), ERROR_NONE); + CHECK_EQ(std::string(ipv4), "192.168.4.1"); + + // The result of the driver is passed on + CHECK_EQ(wifi_access_point_stop(&device), ERROR_INVALID_STATE); + CHECK_EQ(access_point_stop_calls, 1); + + CHECK_EQ(device_stop(&device), ERROR_NONE); + CHECK_EQ(device_remove(&device), ERROR_NONE); + CHECK_EQ(device_destruct(&device), ERROR_NONE); + CHECK_EQ(driver_remove_destruct(&access_point_wifi_driver), ERROR_NONE); +} From 899beb860fb536338056c3d5d75790f0bc3284d6 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Tue, 20 Oct 2026 21:42:19 +0200 Subject: [PATCH 2/9] Remove webserver, update devices --- Data/system/app/WebServer/dashboard.html | 1322 ------------ Data/system/app/WebServer/version.json | 3 - .../heltec-wifi-lora-32-v3/device.properties | 1 - Devices/lilygo-tdisplay-s3/device.properties | 2 +- Devices/lilygo-tdisplay/device.properties | 2 - Devices/lilygo-tdongle-s3/device.properties | 1 - .../lilygo-tdongle-s3/lilygo,tdongle-s3.dts | 5 + Devices/lilygo-tdongle-s3/module.yaml | 1 + .../waveshare-esp32-s3-geek/device.properties | 1 - Devices/waveshare-s3-lcd-13/device.properties | 3 +- Devices/waveshare-s3-lcd-13/module.yaml | 6 +- .../device.properties | 1 - Documentation/ideas.md | 1 - .../source/function_instantiations.cpp | 5 + .../tactility/drivers/esp32_wifi_radio.h | 2 +- .../source/drivers/esp32_wifi.cpp | 2 +- .../Tactility/settings/WebServerSettings.h | 79 - .../service/webserver/WebServerService.h | 112 - Tactility/Source/InitApps.cpp | 4 - Tactility/Source/InitServices.cpp | 2 - .../Source/app/apwebserver/ApWebServer.cpp | 192 -- Tactility/Source/app/launcher/AppsMode.cpp | 1 - .../webserversettings/WebServerSettings.cpp | 431 ---- Tactility/Source/service/webserver/README.md | 373 ---- .../service/webserver/WebServerService.cpp | 1841 ----------------- .../Source/settings/WebServerSettings.cpp | 264 --- 26 files changed, 18 insertions(+), 4639 deletions(-) delete mode 100644 Data/system/app/WebServer/dashboard.html delete mode 100644 Data/system/app/WebServer/version.json delete mode 100644 Tactility/Include/Tactility/settings/WebServerSettings.h delete mode 100644 Tactility/Private/Tactility/service/webserver/WebServerService.h delete mode 100644 Tactility/Source/app/apwebserver/ApWebServer.cpp delete mode 100644 Tactility/Source/app/webserversettings/WebServerSettings.cpp delete mode 100644 Tactility/Source/service/webserver/README.md delete mode 100644 Tactility/Source/service/webserver/WebServerService.cpp delete mode 100644 Tactility/Source/settings/WebServerSettings.cpp diff --git a/Data/system/app/WebServer/dashboard.html b/Data/system/app/WebServer/dashboard.html deleted file mode 100644 index 4e86ff133..000000000 --- a/Data/system/app/WebServer/dashboard.html +++ /dev/null @@ -1,1322 +0,0 @@ - - - - - - Tactility Dashboard - - - -
-
-
-

Tactility Dashboard

-

Loading...

-
-
-
- -
- - -
- 30s -
- -
-
- -
- - - -
- - -
-
-
Loading system information...
-
-
- - -
-
- -
-
- - -
-
- - -
-
- - - -
-
-
- - -
-
- - - -
NameTypeSizeAction
Select the Files tab to browse
-
- - -
-
-
-

Drag & drop an .app file here, or click to select

- - -
-
-

Installed Apps

-
-
Loading apps...
-
-
-
- -
- Tactility WebServer -
-
- - - - diff --git a/Data/system/app/WebServer/version.json b/Data/system/app/WebServer/version.json deleted file mode 100644 index 61a2092b1..000000000 --- a/Data/system/app/WebServer/version.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "version": 1 -} diff --git a/Devices/heltec-wifi-lora-32-v3/device.properties b/Devices/heltec-wifi-lora-32-v3/device.properties index b04527448..26ab0b4f7 100644 --- a/Devices/heltec-wifi-lora-32-v3/device.properties +++ b/Devices/heltec-wifi-lora-32-v3/device.properties @@ -1,7 +1,6 @@ general.vendor=Heltec general.name=WiFi LoRa 32 v3 general.incubating=true -apps.autoStartAppId=tactility.apwebserver hardware.target=ESP32S3 hardware.flashSize=8MB diff --git a/Devices/lilygo-tdisplay-s3/device.properties b/Devices/lilygo-tdisplay-s3/device.properties index b2ee6a8cb..d5802ce77 100644 --- a/Devices/lilygo-tdisplay-s3/device.properties +++ b/Devices/lilygo-tdisplay-s3/device.properties @@ -1,6 +1,5 @@ general.vendor=LilyGO general.name=T-Display S3 -apps.autoStartAppId=tactility.apwebserver hardware.target=ESP32S3 hardware.flashSize=16MB @@ -17,3 +16,4 @@ display.size=1.9" display.dpi=191 lvgl.colorDepth=16 +lvgl.uiDensity=compact diff --git a/Devices/lilygo-tdisplay/device.properties b/Devices/lilygo-tdisplay/device.properties index e326f10c6..6fdf08d08 100644 --- a/Devices/lilygo-tdisplay/device.properties +++ b/Devices/lilygo-tdisplay/device.properties @@ -1,7 +1,5 @@ general.vendor=LilyGO general.name=T-Display -general.incubating=false -apps.autoStartAppId=tactility.apwebserver hardware.target=ESP32 hardware.flashSize=16MB diff --git a/Devices/lilygo-tdongle-s3/device.properties b/Devices/lilygo-tdongle-s3/device.properties index 2b7605813..1760a28d9 100644 --- a/Devices/lilygo-tdongle-s3/device.properties +++ b/Devices/lilygo-tdongle-s3/device.properties @@ -1,7 +1,6 @@ general.vendor=LilyGO general.name=T-Dongle S3 general.incubating=false -apps.autoStartAppId=tactility.apwebserver hardware.target=ESP32S3 hardware.flashSize=16MB diff --git a/Devices/lilygo-tdongle-s3/lilygo,tdongle-s3.dts b/Devices/lilygo-tdongle-s3/lilygo,tdongle-s3.dts index d647c355c..a75eca626 100644 --- a/Devices/lilygo-tdongle-s3/lilygo,tdongle-s3.dts +++ b/Devices/lilygo-tdongle-s3/lilygo,tdongle-s3.dts @@ -102,4 +102,9 @@ compatible = "espressif,esp32-usbdevice-msc"; }; }; + + buttons { + compatible = "tactility,button-control"; + pin-primary = <&gpio0 0 GPIO_FLAG_NONE>; + }; }; diff --git a/Devices/lilygo-tdongle-s3/module.yaml b/Devices/lilygo-tdongle-s3/module.yaml index c372fbead..88e976015 100644 --- a/Devices/lilygo-tdongle-s3/module.yaml +++ b/Devices/lilygo-tdongle-s3/module.yaml @@ -1,5 +1,6 @@ dependencies: - Platforms/platform-esp32 - Drivers/st7735-module + - Drivers/button-control-module dts: lilygo,tdongle-s3.dts dts-only: true diff --git a/Devices/waveshare-esp32-s3-geek/device.properties b/Devices/waveshare-esp32-s3-geek/device.properties index 010bf73c0..dc491cb23 100644 --- a/Devices/waveshare-esp32-s3-geek/device.properties +++ b/Devices/waveshare-esp32-s3-geek/device.properties @@ -1,7 +1,6 @@ general.vendor=Waveshare general.name=ESP32 S3 GEEK general.incubating=false -apps.autoStartAppId=tactility.apwebserver hardware.target=ESP32S3 hardware.flashSize=16MB diff --git a/Devices/waveshare-s3-lcd-13/device.properties b/Devices/waveshare-s3-lcd-13/device.properties index c8d45781e..d7d7d9a7a 100644 --- a/Devices/waveshare-s3-lcd-13/device.properties +++ b/Devices/waveshare-s3-lcd-13/device.properties @@ -1,7 +1,6 @@ general.vendor=WaveShare general.name=S3 LCD 1.3" -general.incubating=false -apps.autoStartAppId=tactility.apwebserver +general.incubating=true hardware.target=ESP32S3 hardware.flashSize=16MB diff --git a/Devices/waveshare-s3-lcd-13/module.yaml b/Devices/waveshare-s3-lcd-13/module.yaml index 88c81fa68..fadee5ea4 100644 --- a/Devices/waveshare-s3-lcd-13/module.yaml +++ b/Devices/waveshare-s3-lcd-13/module.yaml @@ -1,6 +1,6 @@ dependencies: -- Platforms/platform-esp32 -- Drivers/qmi8658-module -- Drivers/st7789-module + - Platforms/platform-esp32 + - Drivers/qmi8658-module + - Drivers/st7789-module dts: waveshare,s3-lcd-13.dts dts-only: true diff --git a/Devices/waveshare-s3-touch-lcd-128/device.properties b/Devices/waveshare-s3-touch-lcd-128/device.properties index c588fd16f..d22b3c449 100644 --- a/Devices/waveshare-s3-touch-lcd-128/device.properties +++ b/Devices/waveshare-s3-touch-lcd-128/device.properties @@ -1,7 +1,6 @@ general.vendor=WaveShare general.name=S3 Touch LCD 1.28" general.incubating=false -apps.autoStartAppId=tactility.apwebserver hardware.target=ESP32S3 hardware.flashSize=16MB diff --git a/Documentation/ideas.md b/Documentation/ideas.md index a4e66a584..106bc5603 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -7,7 +7,6 @@ ## Higher Priority -- Remove tactility.apwebserver from autoStartAppId - make sure all devices use the 1 button control input - Move TactilityKernelCpp project as C++ include folder into TactilityKernel. This way, the kernel itself can use the C++ templates internally. And it's a library less. - GPIO app: fix for monochrome display by using filled vs outline box in secondary colour - BlueTooth as radio backend for MeshCore/Meshtastic? diff --git a/Modules/cpp-symbols-module/source/function_instantiations.cpp b/Modules/cpp-symbols-module/source/function_instantiations.cpp index 2bb299fd8..d3ed17252 100644 --- a/Modules/cpp-symbols-module/source/function_instantiations.cpp +++ b/Modules/cpp-symbols-module/source/function_instantiations.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 #include +#include #ifdef ESP_PLATFORM @@ -10,4 +11,8 @@ template long std::function::operator()() const; template std::function::function(const std::function&); +// operator+(string&&, string&&) is exported by module.cpp too. It is an inline template, so it only exists +// when some code in the firmware calls it. Instantiated here, so the module doesn't depend on that. +template std::string std::operator+(std::string&&, std::string&&); + #endif diff --git a/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h b/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h index ddb660468..745f99384 100644 --- a/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h +++ b/Platforms/platform-esp32/include/tactility/drivers/esp32_wifi_radio.h @@ -11,7 +11,7 @@ extern "C" { /** * Shared ownership over the single ESP32 WiFi radio's esp_wifi_init()/esp_wifi_start() lifecycle. - * Station mode (esp32_wifi driver), ESP-NOW and the WebServer's Access Point mode need the radio and + * Station mode and the access point mode (esp32_wifi driver) and ESP-NOW need the radio and * must not blindly call esp_wifi_init()/esp_wifi_deinit() themselves - doing so unconditionally * breaks whichever owner got there second (failing outright with ESP_ERR_INVALID_STATE, or having * its radio torn out from under it when the other side releases). Callers acquire the mode bit they diff --git a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp index 4a2bff6ff..abf78d209 100644 --- a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp +++ b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp @@ -73,7 +73,7 @@ struct Esp32WifiCtx { #define GET_CTX(device) (static_cast(device_get_driver_data(device))) // Shared radio ownership state for esp32_wifi_radio_acquire()/release() (see esp32_wifi_radio.h). -// Station mode (this driver) and the WebServer's Access Point mode both acquire/release their own +// Station mode and access point mode (this driver) and ESP-NOW acquire/release their own // mode bit rather than calling esp_wifi_init()/deinit() directly, so neither tears down the radio // out from under the other. Mutex g_radioMutex{}; diff --git a/Tactility/Include/Tactility/settings/WebServerSettings.h b/Tactility/Include/Tactility/settings/WebServerSettings.h deleted file mode 100644 index c9ef9676c..000000000 --- a/Tactility/Include/Tactility/settings/WebServerSettings.h +++ /dev/null @@ -1,79 +0,0 @@ -#pragma once - -#include -#include - -namespace tt::settings::webserver { - -#ifdef ESP_PLATFORM -constexpr uint16_t DEFAULT_PORT = 80; -#else -constexpr uint16_t DEFAULT_PORT = 8080; -#endif - -enum class WiFiMode : uint8_t { - Station = 0, // Connect to existing WiFi network - AccessPoint = 1 // Create own WiFi network -}; - -struct WebServerSettings { - // WiFi Configuration - bool wifiEnabled = false; // Enable/disable WiFi entirely - WiFiMode wifiMode = WiFiMode::Station; // Station or Access Point - - // Access Point Mode Settings - std::string apSsid{}; // Default: "Tactility-XXXX" (last 4 of MAC) - std::string apPassword{}; // Password for WPA2 (8-63 chars) - bool apOpenNetwork = false; // If true, create open network (no password) - uint8_t apChannel = 1; // 1-13 - - // Web Server Settings - bool webServerEnabled = false; - uint16_t webServerPort = DEFAULT_PORT; - - // Optional HTTP Basic Auth - bool webServerAuthEnabled = false; - std::string webServerUsername{}; - std::string webServerPassword{}; -}; - -/** - * @brief Load web server settings from persistent storage - * @param[out] settings The settings structure to populate - * @return true if settings were loaded successfully, false otherwise - */ -bool load(WebServerSettings& settings); - -/** - * @brief Get default web server settings - * @return Default settings structure - */ -WebServerSettings getDefault(); - -/** - * @brief Load settings or return defaults if loading fails - * @return Settings structure (either loaded or default) - */ -WebServerSettings loadOrGetDefault(); - -/** - * @brief Save web server settings to persistent storage - * @param[in] settings The settings to save - * @return true if settings were saved successfully, false otherwise - */ -bool save(const WebServerSettings& settings); - -/** - * @brief Generate default AP SSID based on device MAC address - * @return SSID string in format "Tactility-XXXX" - */ -std::string generateDefaultApSsid(); - -/** - * @brief Generate a cryptographically secure random string for credentials - * @param length The desired length of the string - * @return A random alphanumeric string - */ -std::string generateRandomCredential(size_t length); - -} diff --git a/Tactility/Private/Tactility/service/webserver/WebServerService.h b/Tactility/Private/Tactility/service/webserver/WebServerService.h deleted file mode 100644 index dc414afec..000000000 --- a/Tactility/Private/Tactility/service/webserver/WebServerService.h +++ /dev/null @@ -1,112 +0,0 @@ -#pragma once - -#include -#include -#include - -#include - -#ifdef ESP_PLATFORM -#include -#endif - -#include - -namespace tt::service::webserver { - -enum class WebServerEvent { - /** WebServer settings have been modified (WiFi/HTTP credentials, enable/disable states) */ - WebServerSettingsChanged, - /** HTTP server has started and is accepting connections */ - WebServerStarted, - /** HTTP server has stopped and is no longer accepting connections */ - WebServerStopped -}; - -/** - * @brief Web server service with resilient asset architecture - * - * Provides: - * - Core HTML endpoints (hardcoded in firmware) - * - Dynamic asset serving from Data partition - * - SD card fallback - * - Asset synchronization - */ -class WebServerService final : public Service { -private: - mutable RecursiveMutex mutex; - struct HttpServer* httpServer = nullptr; - PubSub::SubscriptionHandle settingsEventSubscription = nullptr; - std::shared_ptr> pubsub = std::make_shared>(); - int8_t statusbarIconId = -1; // Statusbar icon for WebServer state - - // AP mode WiFi management - real hardware only, see startApMode()/stopApMode()'s own - // non-ESP_PLATFORM definitions. - bool apWifiInitialized = false; -#ifdef ESP_PLATFORM - esp_netif_t* apNetif = nullptr; -#endif - - bool startApMode(); - void stopApMode(); - - // Core HTML endpoints (hardcoded in firmware) - static error_t handleRoot(struct HttpServerRequest* request, void* user_ctx); - static error_t handleReboot(struct HttpServerRequest* request, void* user_ctx); - - // File browser endpoints - static error_t handleFileBrowser(struct HttpServerRequest* request, void* user_ctx); - static error_t handleFsList(struct HttpServerRequest* request, void* user_ctx); - static error_t handleFsTree(struct HttpServerRequest* request, void* user_ctx); - static error_t handleFsDownload(struct HttpServerRequest* request, void* user_ctx); - static error_t handleFsMkdir(struct HttpServerRequest* request, void* user_ctx); - static error_t handleFsDelete(struct HttpServerRequest* request, void* user_ctx); - static error_t handleFsRename(struct HttpServerRequest* request, void* user_ctx); - static error_t handleFsUpload(struct HttpServerRequest* request, void* user_ctx); - // Consolidated dispatch handlers to reduce URI handler table usage - static error_t handleFsGenericGet(struct HttpServerRequest* request, void* user_ctx); - static error_t handleFsGenericPost(struct HttpServerRequest* request, void* user_ctx); - // Admin dispatcher to consolidate small POST endpoints (sync/reboot) - static error_t handleAdminPost(struct HttpServerRequest* request, void* user_ctx); - - // API endpoints - static error_t handleApiGet(struct HttpServerRequest* request, void* user_ctx); - static error_t handleApiPost(struct HttpServerRequest* request, void* user_ctx); - static error_t handleApiPut(struct HttpServerRequest* request, void* user_ctx); - static error_t handleApiSysinfo(struct HttpServerRequest* request, void* user_ctx); - static error_t handleApiApps(struct HttpServerRequest* request, void* user_ctx); - static error_t handleApiAppsRun(struct HttpServerRequest* request, void* user_ctx); - static error_t handleApiAppsUninstall(struct HttpServerRequest* request, void* user_ctx); - static error_t handleApiAppsInstall(struct HttpServerRequest* request, void* user_ctx); - static error_t handleApiWifi(struct HttpServerRequest* request, void* user_ctx); - static error_t handleApiScreenshot(struct HttpServerRequest* request, void* user_ctx); - - // Dynamic asset serving - static error_t handleAssets(struct HttpServerRequest* request, void* user_ctx); - - bool startServer(); - void stopServer(); - -public: - - ~WebServerService() override; - - bool onStart(ServiceContext& service) override; - void onStop(ServiceContext& service) override; - - void setEnabled(bool enabled); - bool isEnabled() const; - - std::shared_ptr> getPubsub() const { return pubsub; } -}; - -// Global accessor for controlling the WebServer service -void setWebServerEnabled(bool enabled); - -// Returns whether the HTTP server is actually running right now (not just the persisted setting) -bool isWebServerEnabled(); - -// Get the pubsub for subscribing to WebServer events -std::shared_ptr> getPubsub(); - -} // namespace diff --git a/Tactility/Source/InitApps.cpp b/Tactility/Source/InitApps.cpp index 5f046fe5d..02217422d 100644 --- a/Tactility/Source/InitApps.cpp +++ b/Tactility/Source/InitApps.cpp @@ -87,9 +87,7 @@ namespace app { namespace wificonnect { extern const ::AppManifest manifest; } namespace wifimanage { extern const ::AppManifest manifest; } - namespace webserversettings { extern const ::AppManifest manifest; } #ifdef ESP_PLATFORM - namespace apwebserver { extern const ::AppManifest manifest; } namespace camera { extern const ::AppManifest manifest; } #if CONFIG_TT_TDECK_WORKAROUND == 1 namespace keyboardsettings { extern const ::AppManifest manifest; } // T-Deck only for now @@ -151,9 +149,7 @@ static void registerInternalApps() { app_manager_add(&app::development::manifest); app_manager_add(&app::developmentprompt::manifest); - app_manager_add(&app::webserversettings::manifest); #ifdef ESP_PLATFORM - app_manager_add(&app::apwebserver::manifest); if (device_exists_of_type(&CAMERA_TYPE)) { app_manager_add(&app::camera::manifest); } diff --git a/Tactility/Source/InitServices.cpp b/Tactility/Source/InitServices.cpp index 8d0d2f824..4c920dfdf 100644 --- a/Tactility/Source/InitServices.cpp +++ b/Tactility/Source/InitServices.cpp @@ -36,7 +36,6 @@ namespace service { #ifdef ESP_PLATFORM namespace rtctime { extern const ServiceManifest manifest; } #endif - namespace webserver { extern const ServiceManifest manifest; } } @@ -57,7 +56,6 @@ void registerAndStartServices() { check(module_ensure_started(&mesh_meshcore_module) == ERROR_NONE); check(module_ensure_started(&mesh_meshtastic_module) == ERROR_NONE); check(module_ensure_started(&development_module) == ERROR_NONE); - addService(service::webserver::manifest); #if defined(__linux__) || defined(__APPLE__) check(posix_services_start() == ERROR_NONE); #endif diff --git a/Tactility/Source/app/apwebserver/ApWebServer.cpp b/Tactility/Source/app/apwebserver/ApWebServer.cpp deleted file mode 100644 index e91e27fe3..000000000 --- a/Tactility/Source/app/apwebserver/ApWebServer.cpp +++ /dev/null @@ -1,192 +0,0 @@ -#ifdef ESP_PLATFORM - -#include -#include -#include - -#include -#include -#include -#include - -#include - -#include -#include -#include -#include - -namespace tt::app::apwebserver { - -constexpr auto* TAG = "ApWebServerApp"; - -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; -}; - - -void onBackPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - app_event_emit_close(ctx->appInstanceId); -} - -void createWidgets(lv_obj_t* parent, void* userData) { - auto* ctx = static_cast(userData); - - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_scrollable(parent, false); - - 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_style_border_width(wrapper, 0, LV_STATE_DEFAULT); - 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(int argc, char* argv[]) { - uint32_t appInstanceId = app_scheduler_current_app_id(); - Context ctx {}; - ctx.appInstanceId = appInstanceId; - ctx.wsSettings = settings::webserver::loadOrGetDefault(); - - TaskEventGroup event_group {}; - task_event_group_construct(&event_group); - - AppEventSubscription sub {}; - check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); - - WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - - bool shouldClose = false; - while (!shouldClose) { - task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); - - AppEvent event {}; - while (app_event_poll(&sub, &event) == ERROR_NONE) { - switch (event.type) { - case APP_EVENT_CLOSE: - shouldClose = true; - break; - default: - break; - } - if (shouldClose) 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); - check(app_event_unsubscribe(&sub) == ERROR_NONE); - task_event_group_destruct(&event_group); - - return 0; -} - -} // namespace - -extern const ::AppManifest manifest = { - .id = "tactility.apwebserver", - .name = "AP Web Server", - .category = APP_CATEGORY_SYSTEM, - .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, - .flags = APP_MANIFEST_FLAG_HIDDEN, - .stack = {} -}; - -} // namespace tt::app::apwebserver - -#endif diff --git a/Tactility/Source/app/launcher/AppsMode.cpp b/Tactility/Source/app/launcher/AppsMode.cpp index bf6e3fe5c..ebd3db2a0 100644 --- a/Tactility/Source/app/launcher/AppsMode.cpp +++ b/Tactility/Source/app/launcher/AppsMode.cpp @@ -51,7 +51,6 @@ constexpr IconEntry ICONS[] = { {"tactility.notes", LVGL_ICON_SHARED_EDIT_NOTE}, {"tactility.systeminfo", LVGL_ICON_SHARED_DEVICES}, {"tactility.terminal", LVGL_ICON_SHARED_TERMINAL}, - {"tactility.webserversettings", LVGL_ICON_SHARED_CLOUD}, }; const char* appIcon(const ::AppManifest* manifest) { diff --git a/Tactility/Source/app/webserversettings/WebServerSettings.cpp b/Tactility/Source/app/webserversettings/WebServerSettings.cpp deleted file mode 100644 index e218156b3..000000000 --- a/Tactility/Source/app/webserversettings/WebServerSettings.cpp +++ /dev/null @@ -1,431 +0,0 @@ -#include -#include -#include - -#include -#include -#include -#include - -#include - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace tt::app::webserversettings { - -constexpr auto* TAG = "WebServerSettingsApp"; - -extern const ::AppManifest manifest; - -namespace { - -struct Context { - uint32_t appInstanceId; - - settings::webserver::WebServerSettings wsSettings; - settings::webserver::WebServerSettings originalSettings; - bool updated = false; - bool wifiSettingsChanged = false; - lv_obj_t* wifiModeChips = nullptr; - // Shown in access point mode - lv_obj_t* apGroup = nullptr; - // Shown when the access point isn't an open network - lv_obj_t* apPasswordRow = nullptr; - // Shown when authentication is required - lv_obj_t* authCard = nullptr; - // Shown when the web server is enabled - lv_obj_t* urlCard = nullptr; - lv_obj_t* textAreaApPassword = nullptr; - lv_obj_t* switchApOpenNetwork = nullptr; - lv_obj_t* switchWebServerEnabled = nullptr; - lv_obj_t* switchWebServerAuthEnabled = nullptr; - lv_obj_t* textAreaWebServerUsername = nullptr; - lv_obj_t* textAreaWebServerPassword = nullptr; - lv_obj_t* labelUrl = nullptr; - lv_obj_t* labelUrlValue = nullptr; -}; - - -void updateUrlDisplay(Context* ctx); -void createWidgets(lv_obj_t* parent, void* userData); - -void onBackPressed(lv_event_t* event) { - auto* ctx = static_cast(lv_event_get_user_data(event)); - app_event_emit_close(ctx->appInstanceId); -} - -/** Shows the widgets of the enabled features. Requires the LVGL lock. */ -void updateVisibility(Context* ctx, bool accessPoint, bool openNetwork, bool authEnabled, bool serverEnabled) { - const uint32_t chip_count = lv_obj_get_child_count(ctx->wifiModeChips); - for (uint32_t i = 0; i < chip_count; i++) { - lv_obj_set_state(lv_obj_get_child(ctx->wifiModeChips, static_cast(i)), LV_STATE_CHECKED, (i == 1) == accessPoint); - } - lv_obj_set_hidden(ctx->apGroup, !accessPoint); - lv_obj_set_hidden(ctx->apPasswordRow, openNetwork); - lv_obj_set_hidden(ctx->authCard, !authEnabled); - lv_obj_set_hidden(ctx->urlCard, !serverEnabled); -} - -void updateVisibility(Context* ctx) { - updateVisibility(ctx, - lv_obj_has_state(lv_obj_get_child(ctx->wifiModeChips, 1), LV_STATE_CHECKED), - lv_obj_has_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED), - lv_obj_has_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED), - lv_obj_has_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED)); -} - -void onWifiModeChipPressed(lv_event_t* e) { - auto* ctx = static_cast(lv_event_get_user_data(e)); - // Chip 0 is station mode, chip 1 is access point mode - const uint32_t index = lv_obj_get_index(lv_event_get_target_obj(e)); - updateVisibility(ctx, index == 1, - lv_obj_has_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED), - lv_obj_has_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED), - lv_obj_has_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED)); - getMainDispatcher().dispatch([ctx, index] { - ctx->wsSettings.wifiMode = static_cast(index); - ctx->updated = true; - ctx->wifiSettingsChanged = true; - lvgl_lock(); - updateUrlDisplay(ctx); - lvgl_unlock(); - }); -} - -void onWebServerEnabledSwitch(lv_event_t* e) { - auto* ctx = static_cast(lv_event_get_user_data(e)); - bool enabled = lv_obj_has_state(ctx->switchWebServerEnabled, LV_STATE_CHECKED); - updateVisibility(ctx); - 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(lv_event_get_user_data(e)); - bool enabled = lv_obj_has_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED); - updateVisibility(ctx); - - getMainDispatcher().dispatch([ctx, enabled] { - ctx->wsSettings.webServerAuthEnabled = enabled; - ctx->updated = true; - }); -} - -void onCredentialChanged(lv_event_t* e) { - auto* ctx = static_cast(lv_event_get_user_data(e)); - getMainDispatcher().dispatch([ctx] { - ctx->updated = true; - }); -} - -void onApPasswordChanged(lv_event_t* e) { - auto* ctx = static_cast(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(lv_event_get_user_data(e)); - bool openNetwork = lv_obj_has_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED); - updateVisibility(ctx); - - 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 - char ip[16] = {}; - Device* wifi_device = nullptr; - if (device_get_first_by_type(&WIFI_TYPE, &wifi_device) == ERROR_NONE) { - wifi_station_get_ipv4_address(wifi_device, ip); - device_put(wifi_device); - } - if (ip[0] != '\0') { - url += ip; - } 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()); -} - -lv_obj_t* createCard(lv_obj_t* parent) { - auto* card = lvgl_card_create(parent); - lv_obj_set_size(card, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); - return card; -} - -/** A transparent column of rows, to show or hide them together */ -lv_obj_t* createGroup(lv_obj_t* parent) { - auto* group = lv_obj_create(parent); - lv_obj_set_size(group, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(group, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_all(group, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(group, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(group, LV_OPA_TRANSP, LV_STATE_DEFAULT); - lv_obj_set_scrollable(group, false); - return group; -} - -/** A transparent row: "Title [content]" */ -lv_obj_t* createRow(lv_obj_t* parent, const char* title) { - auto* row = lv_obj_create(parent); - lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(row, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_pad_all(row, 0, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(row, 0, LV_STATE_DEFAULT); - lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, LV_STATE_DEFAULT); - lv_obj_set_scrollable(row, false); - auto* label = lv_label_create(row); - lv_label_set_text(label, title); - lv_obj_set_flex_grow(label, 1); - return row; -} - -lv_obj_t* createTextArea(lv_obj_t* row, const char* text, uint32_t maxLength, bool password) { - auto* text_area = lv_textarea_create(row); - lv_obj_set_width(text_area, LV_PCT(50)); - lv_textarea_set_one_line(text_area, true); - lv_textarea_set_max_length(text_area, maxLength); - lv_textarea_set_password_mode(text_area, password); - lv_textarea_set_text(text_area, text); - return text_area; -} - -void createWidgets(lv_obj_t* parent, void* userData) { - auto* ctx = static_cast(userData); - - lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); - lv_obj_set_style_pad_row(parent, 0, LV_STATE_DEFAULT); - - 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_style_border_width(main_wrapper, 0, LV_STATE_DEFAULT); - 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); - - // Address, while the web server is enabled - ctx->urlCard = createCard(main_wrapper); - ctx->labelUrl = lv_label_create(ctx->urlCard); - lv_label_set_text(ctx->labelUrl, "Web server address"); - ctx->labelUrlValue = lv_label_create(ctx->urlCard); - updateUrlDisplay(ctx); - - // WiFi mode, with the access point settings in access point mode - lv_label_set_text(lv_label_create(main_wrapper), "WiFi mode"); - auto* wifi_card = createCard(main_wrapper); - lv_obj_set_flex_align(wifi_card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - - // The chips are centered while they fit, and scroll when they don't - ctx->wifiModeChips = lv_obj_create(wifi_card); - lv_obj_set_size(ctx->wifiModeChips, LV_SIZE_CONTENT, LV_SIZE_CONTENT); - lv_obj_set_style_max_width(ctx->wifiModeChips, LV_PCT(100), LV_STATE_DEFAULT); - lv_obj_set_flex_flow(ctx->wifiModeChips, LV_FLEX_FLOW_ROW); - lv_obj_set_scroll_dir(ctx->wifiModeChips, LV_DIR_HOR); - lv_obj_set_style_bg_opa(ctx->wifiModeChips, LV_OPA_TRANSP, LV_STATE_DEFAULT); - lv_obj_set_style_border_width(ctx->wifiModeChips, 0, LV_STATE_DEFAULT); - // The chips' margins leave room for their focus rings and space them apart - lv_obj_set_style_pad_all(ctx->wifiModeChips, 0, LV_STATE_DEFAULT); - lv_obj_set_style_pad_column(ctx->wifiModeChips, 0, LV_STATE_DEFAULT); - // In settings::webserver::WiFiMode order - for (const char* name : { "Station", "Access point" }) { - auto* chip = lvgl_chip_create(ctx->wifiModeChips); - lv_label_set_text(lv_label_create(chip), name); - lv_obj_add_event_cb(chip, onWifiModeChipPressed, LV_EVENT_SHORT_CLICKED, ctx); - } - lvgl_grid_navigation_add(ctx->wifiModeChips); - - ctx->apGroup = createGroup(wifi_card); - auto* ap_open_row = createRow(ctx->apGroup, "Open network"); - ctx->switchApOpenNetwork = lv_switch_create(ap_open_row); - if (ctx->wsSettings.apOpenNetwork) lv_obj_add_state(ctx->switchApOpenNetwork, LV_STATE_CHECKED); - lv_obj_add_event_cb(ctx->switchApOpenNetwork, onApOpenNetworkSwitch, LV_EVENT_VALUE_CHANGED, ctx); - - ctx->apPasswordRow = createRow(ctx->apGroup, "Password"); - ctx->textAreaApPassword = createTextArea(ctx->apPasswordRow, ctx->wsSettings.apPassword.c_str(), 64, true); - lv_obj_add_event_cb(ctx->textAreaApPassword, onApPasswordChanged, LV_EVENT_VALUE_CHANGED, ctx); - - // Authentication, with the credentials while it's required - auto* auth_row = createRow(main_wrapper, "Require authentication"); - ctx->switchWebServerAuthEnabled = lv_switch_create(auth_row); - if (ctx->wsSettings.webServerAuthEnabled) lv_obj_add_state(ctx->switchWebServerAuthEnabled, LV_STATE_CHECKED); - lv_obj_add_event_cb(ctx->switchWebServerAuthEnabled, onWebServerAuthEnabledSwitch, LV_EVENT_VALUE_CHANGED, ctx); - - ctx->authCard = createCard(main_wrapper); - auto* user_row = createRow(ctx->authCard, "Username"); - ctx->textAreaWebServerUsername = createTextArea(user_row, ctx->wsSettings.webServerUsername.c_str(), 32, false); - lv_obj_add_event_cb(ctx->textAreaWebServerUsername, onCredentialChanged, LV_EVENT_VALUE_CHANGED, ctx); - auto* password_row = createRow(ctx->authCard, "Password"); - ctx->textAreaWebServerPassword = createTextArea(password_row, ctx->wsSettings.webServerPassword.c_str(), 64, true); - lv_obj_add_event_cb(ctx->textAreaWebServerPassword, onCredentialChanged, LV_EVENT_VALUE_CHANGED, 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(100)); - 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."); - - updateVisibility(ctx, - ctx->wsSettings.wifiMode == settings::webserver::WiFiMode::AccessPoint, - ctx->wsSettings.apOpenNetwork, - ctx->wsSettings.webServerAuthEnabled, - ctx->wsSettings.webServerEnabled); -} - -int32_t appMain(int argc, char* argv[]) { - uint32_t appInstanceId = app_scheduler_current_app_id(); - 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; - - TaskEventGroup event_group {}; - task_event_group_construct(&event_group); - - AppEventSubscription sub {}; - check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); - - WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - - bool shouldClose = false; - while (!shouldClose) { - task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); - - AppEvent event {}; - while (app_event_poll(&sub, &event) == ERROR_NONE) { - switch (event.type) { - case APP_EVENT_CLOSE: - shouldClose = true; - break; - default: - break; - } - if (shouldClose) 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); - - // 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"); - } - }); - } - - window_manager_remove(window); - check(app_event_unsubscribe(&sub) == ERROR_NONE); - task_event_group_destruct(&event_group); - - return 0; -} - -} // namespace - -extern const ::AppManifest manifest = { - .id = "tactility.webserversettings", - .name = "Web Server", - .category = APP_CATEGORY_SYSTEM, - .location = { APP_LOCATION_MEMORY, reinterpret_cast(appMain) }, - .flags = 0, - .stack = {} -}; - -} diff --git a/Tactility/Source/service/webserver/README.md b/Tactility/Source/service/webserver/README.md deleted file mode 100644 index aabfa9fb7..000000000 --- a/Tactility/Source/service/webserver/README.md +++ /dev/null @@ -1,373 +0,0 @@ -# WebServer Service - -The WebServer service provides a built-in HTTP server for remote device management, file operations, and system monitoring through a web browser. - -## Features - -- **Dashboard**: Real-time system information, memory stats, and storage overview -- **File Browser**: Navigate, upload, download, rename, and delete files on internal storage and SD card -- **App Management**: List installed apps, run apps remotely, install/uninstall external apps -- **WiFi Status**: View current WiFi connection details -- **Screenshot Capture**: Capture the current display as a PNG -- **System Controls**: Reboot device - -## Enabling the WebServer - -The WebServer is disabled by default to conserve memory. Enable it through: - -1. **Settings App**: Navigate to Settings > WebServer Settings -2. **Programmatically**: Call `tt::service::webserver::setWebServerEnabled(true)` - -When enabled, a statusbar icon appears indicating the server mode (AP or Station). - -## Accessing the Dashboard - -Once enabled, access the dashboard by navigating to the device's IP address in a web browser: - -```text -http:/// -``` - -**Access Point Mode:** When using AP mode, connect to the device's WiFi network (SSID shown in settings, default `Tactility-XXXX`) and navigate to `http://192.168.4.1/` - -The root URL redirects to `/dashboard.html` which provides a tabbed interface for all features. - -## API Endpoints - -All API endpoints return JSON responses unless otherwise noted. - -### System Information - -#### GET /api/sysinfo - -Returns comprehensive system information. - -**Response:** -```json -{ - "firmware": { - "version": "1.0.0", - "idf_version": "5.3.0" - }, - "chip": { - "model": "ESP32-S3", - "cores": 2, - "revision": 0, - "features": ["Embedded Flash", "WiFi 2.4GHz", "BLE"], - "flash_size": 16777216 - }, - "heap": { - "free": 123456, - "total": 327680, - "min_free": 100000, - "largest_block": 65536 - }, - "psram": { - "free": 4000000, - "total": 8388608, - "min_free": 3500000, - "largest_block": 2000000 - }, - "storage": { - "data": { - "free": 1000000, - "total": 3145728, - "mounted": true - }, - "sdcard": { - "free": 15000000000, - "total": 32000000000, - "mounted": true - } - }, - "uptime": 3600, - "task_count": 25 -} -``` - -### WiFi Status - -#### GET /api/wifi - -Returns current WiFi connection status. - -**Response:** -```json -{ - "state": "connected", - "ip": "192.168.1.100", - "ssid": "MyNetwork", - "rssi": -45, - "secure": true -} -``` - -**State values:** -- `off` - WiFi radio is off -- `turning_on` - WiFi is starting -- `turning_off` - WiFi is stopping -- `on` - WiFi is on but not connected -- `connecting` - Connection in progress -- `connected` - Connected to access point - -### Screenshot - -#### GET /api/screenshot - -Captures the current display and returns a PNG. The screenshot is also saved to storage with an incrementing filename. - -**Response:** PNG data (`image/png`) - -**Save Location:** -- SD card root (if mounted): `/sdcard/webscreenshot1.png`, `/sdcard/webscreenshot2.png`, etc. -- Internal storage (fallback): `/data/webscreenshot1.png`, `/data/webscreenshot2.png`, etc. - -**Requirements:** `TT_FEATURE_SCREENSHOT_ENABLED` must be defined in the build. - -**Note:** Returns 501 Not Implemented if screenshot feature is disabled. - -### App Management - -#### GET /api/apps - -Lists all installed applications. - -**Response:** -```json -{ - "apps": [ - { - "id": "com.example.myapp", - "name": "My App", - "version": "1.0.0", - "category": "user", - "isExternal": true, - "hidden": false, - "icon": "/data/app/com.example.myapp/icon.png" - } - ] -} -``` - -**Category values:** `user`, `system`, `settings` - -#### POST /api/apps/run?id=xxx - -Runs an application by its ID. If the app is already running, it will be stopped first. - -**Parameters:** -- `id` (required): Application ID - -**Response:** `ok` on success - -#### POST /api/apps/uninstall?id=xxx - -Uninstalls an external application. System apps cannot be uninstalled. - -**Parameters:** -- `id` (required): Application ID - -**Response:** `ok` on success - -**Errors:** -- 403 Forbidden: Cannot uninstall system apps -- 500 Internal Server Error: Uninstall failed - -#### PUT /api/apps/install - -Installs an application from an uploaded `.app` file (tar archive). - -**Content-Type:** `multipart/form-data` - -**Form field:** `file` - The `.app` file to install - -**Response:** `ok` on success - -### File System Operations - -#### GET /fs/list?path=/path - -Lists directory contents. - -**Parameters:** -- `path` (optional): Directory path. Defaults to `/` which shows mount points. - -**Response:** -```json -{ - "path": "/data", - "entries": [ - {"name": "app", "type": "dir", "size": 0}, - {"name": "settings.json", "type": "file", "size": 1234} - ] -} -``` - -**Special paths:** -- `/` - Shows available mount points (data, sdcard if mounted) -- `/data` - Internal flash storage -- `/sdcard` - SD card (if mounted) - -#### GET /fs/download?path=/path/to/file - -Downloads a file. - -**Parameters:** -- `path` (required): Full path to the file - -**Response:** File contents with appropriate Content-Type header and Content-Disposition for download. - -#### POST /fs/upload?path=/path/to/file - -Uploads a file. The request body contains the raw file data. - -**Parameters:** -- `path` (required): Full destination path including filename - -**Content-Type:** Any (raw file data in body) - -**Response:** `Uploaded X bytes` - -**Limits:** Maximum file size is 10MB. - -#### POST /fs/mkdir?path=/path/to/newdir - -Creates a new directory. - -**Parameters:** -- `path` (required): Full path of directory to create - -**Response:** `ok` on success - -#### POST /fs/delete?path=/path/to/item - -Deletes a file or directory (recursive for directories). - -**Parameters:** -- `path` (required): Full path to delete - -**Response:** `ok` on success - -**Restrictions:** Cannot delete mount points (`/data`, `/sdcard`). - -#### POST /fs/rename?path=/path/to/old&newName=newname - -Renames a file or directory. - -**Parameters:** -- `path` (required): Full path to the item to rename -- `newName` (required): New name (filename only, not a path) - -**Response:** `ok` on success - -**Restrictions:** -- `newName` cannot contain path separators or `..` -- Cannot overwrite existing items - -#### GET /fs/tree - -Returns a tree structure of all mount points and their immediate contents. - -**Response:** -```json -{ - "mounts": [ - { - "name": "data", - "path": "/data", - "entries": [ - {"name": "app", "type": "dir"}, - {"name": "tmp", "type": "dir"} - ] - } - ] -} -``` - -### Admin Operations - -#### POST /admin/reboot - -Reboots the device after a 1-second delay. - -**Response:** `Rebooting...` - -## Static Assets - -`dashboard.html`, `version.json` and `favicon.ico` are embedded in the firmware from `Data/system/app/WebServer/`. - -Other files are served from `/sdcard/tactility/webserver/` (SD card). - -## Security Considerations - -> **⚠️ Security Warning**: The WebServer is unauthenticated by default, allowing anyone on the network to: -> - Upload, download, and delete files -> - Install and uninstall applications -> - Reboot the device -> - Capture screenshots -> -> **Strongly recommended**: -> - Enable HTTP Basic Authentication in Settings > Web Server before exposing the device to untrusted networks -> - Keep "AP Open Network" disabled (use WPA2 password protection) to prevent unauthorized network access - -- **⚠️ Open Network Option**: The "AP Open Network" setting allows creating an unprotected access point without a password. **This is convenient for quick access but exposes the device to anyone within WiFi range**, potentially allowing unauthorized access to all WebServer functionality if HTTP authentication is also disabled. -- **Automatic credential generation**: Credentials are automatically generated when empty: - - **AP Password**: Generated when empty (unless "AP Open Network" is enabled) - - **HTTP Auth**: Generated when auth is enabled but username or password are empty - - Generated credentials are 12 alphanumeric characters (~71 bits of entropy) and persisted immediately - - User-set credentials are preserved (the system only replaces empty credentials, not weak user-chosen passwords) - - Check Settings > Web Server to view the generated credentials -- File operations are restricted to `/data` and `/sdcard` paths -- Path traversal attacks are blocked (e.g., `../` is rejected) -- Mount points cannot be deleted -- System apps cannot be uninstalled via the API - -## Configuration - -Settings are stored in the WebServer settings file and can be configured via **Settings > Web Server**: - -| Setting | Description | Default | -|---------|-------------|---------| -| WiFi Mode | Station (connect to existing network) or Access Point (create own network) | Station | -| AP Open Network | Create an open AP without password protection | Disabled | -| AP Password | Password for Access Point mode (WPA2, 8-63 chars). Disabled when Open Network is enabled. | Auto-generated | -| Web Server Enabled | Whether the HTTP server is running | Disabled | -| Require Authentication | Enable HTTP Basic Authentication | Disabled | -| Username | Authentication username (when auth enabled) | Auto-generated | -| Password | Authentication password (when auth enabled) | Auto-generated | - -**Note:** The system automatically generates secure credentials when they are empty. Generated credentials are 12-character alphanumeric strings with ~71 bits of entropy. See **Security Considerations** for details. - -**Note:** WiFi Station credentials are managed separately via the WiFi settings menu. - -## Statusbar Icons - -When the WebServer is running, a statusbar icon indicates the WiFi mode: -- `webserver_ap_white.png` - Access Point mode -- `webserver_station_white.png` - Station mode - -## Events - -The WebServer publishes events: -- `WebServerStarted` - Fired when the HTTP server starts -- `WebServerStopped` - Fired when the HTTP server stops -- `WebServerSettingsChanged` - Fired when settings are modified - -## Troubleshooting - -### "No slots left for registering handler" - -The ESP-IDF HTTP server has a limit on URI handlers. The WebServer configures this dynamically based on the number of handlers needed, but if you see this error, check `CONFIG_HTTPD_MAX_URI_HANDLERS` in sdkconfig. - -### Screenshot fails - -- Verify `TT_FEATURE_SCREENSHOT_ENABLED` is defined -- Check available heap memory (screenshot requires ~width*height*3 bytes) -- Ensure the save location (SD card or `/data`) is writable -- Screenshots are saved as `webscreenshot1.png`, `webscreenshot2.png`, etc. up to 9999 - -### File upload fails - -- Check file size is under 10MB limit -- Verify the destination path is writable -- Ensure the parent directory exists diff --git a/Tactility/Source/service/webserver/WebServerService.cpp b/Tactility/Source/service/webserver/WebServerService.cpp deleted file mode 100644 index 897289336..000000000 --- a/Tactility/Source/service/webserver/WebServerService.cpp +++ /dev/null @@ -1,1841 +0,0 @@ -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include -#include - -#if TT_FEATURE_SCREENSHOT_ENABLED -#include -#endif - -#include "app/install.h" -#include "app/manager.h" - -#ifdef ESP_PLATFORM -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#endif - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace tt::service::webserver { - -constexpr auto* TAG = "WebServerService"; - -#ifdef ESP_PLATFORM -// Helper to convert chip model enum to human-readable string -static const char* getChipModelName(esp_chip_model_t model) { - switch (model) { - case CHIP_ESP32: return "ESP32"; - case CHIP_ESP32S2: return "ESP32-S2"; - case CHIP_ESP32S3: return "ESP32-S3"; - case CHIP_ESP32C3: return "ESP32-C3"; - case CHIP_ESP32C2: return "ESP32-C2"; - case CHIP_ESP32C6: return "ESP32-C6"; - case CHIP_ESP32H2: return "ESP32-H2"; - case CHIP_ESP32P4: return "ESP32-P4"; - case CHIP_ESP32C5: return "ESP32-C5"; - case CHIP_ESP32C61: return "ESP32-C61"; - default: return "Unknown"; - } -} -#endif - -// Cached settings to avoid SD card reads on every HTTP request -static Mutex g_settingsMutex; -static settings::webserver::WebServerSettings g_cachedSettings; -static bool g_settingsCached = false; - -// Global instance pointer for controlling the service (atomic to prevent TOCTOU races) -static std::atomic g_webServerInstance{nullptr}; - -constexpr int MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10 MB limit - -static void publish_event(WebServerService* webserver, WebServerEvent event) { - webserver->getPubsub()->publish(event); -} - -std::shared_ptr> getPubsub() { - WebServerService* webserver = g_webServerInstance.load(); - if (webserver == nullptr) { - check(false, "Service not running"); - } - - return webserver->getPubsub(); -} - -static bool secureCompare(const std::string& a, const std::string& b) { - size_t maxLen = std::max(a.size(), b.size()); - volatile unsigned char result = 0; - result |= (a.size() != b.size()); - for (size_t i = 0; i < maxLen; ++i) { - unsigned char ca = (i < a.size()) ? static_cast(a[i]) : 0; - unsigned char cb = (i < b.size()) ? static_cast(b[i]) : 0; - result |= ca ^ cb; - } - return result == 0; -} - -// Helper to send 401 Unauthorized response with WWW-Authenticate header -static error_t sendUnauthorized(HttpServerRequest* request, const char* message) { - http_server_request_set_header(request, "WWW-Authenticate", "Basic realm=\"Tactility\""); - http_server_request_send_error(request, 401, message); - return ERROR_NONE; // Response was sent successfully -} - -// Helper to validate HTTP Basic Auth on sensitive endpoints -// Returns ERROR_NONE with authPassed=true if auth succeeded or is disabled -// Returns ERROR_NONE with authPassed=false if auth failed (401 response already sent) -static error_t validateRequestAuth(HttpServerRequest* request, bool& authPassed) { - authPassed = false; - - // Copy settings under lock to avoid race with settings update callback - settings::webserver::WebServerSettings settings; - { - auto lock = g_settingsMutex.asScopedLock(); - lock.lock(); - settings = g_cachedSettings; - } - - if (!settings.webServerAuthEnabled) { - authPassed = true; - return ERROR_NONE; // Auth disabled, allow request - } - - // Get Authorization header - size_t auth_len = http_server_request_get_header(request, "Authorization", nullptr, 0); - if (auth_len == 0) { - return sendUnauthorized(request, "Authorization required"); - } - std::string auth_header(auth_len, '\0'); - http_server_request_get_header(request, "Authorization", auth_header.data(), auth_len + 1); - - // Check for "Basic " prefix - if (auth_header.rfind("Basic ", 0) != 0) { - LOG_W(TAG, "Authorization header is not Basic auth"); - return sendUnauthorized(request, "Basic authorization required"); - } - - // Extract base64 encoded credentials - std::string base64_creds = auth_header.substr(6); - - // Decode base64 using mbedtls (available in ESP-IDF) - size_t decoded_len = 0; - // First pass to get length - mbedtls_base64_decode(nullptr, 0, &decoded_len, - reinterpret_cast(base64_creds.c_str()), - base64_creds.length()); - - std::string decoded(decoded_len, '\0'); - size_t actual_len = 0; - int ret = mbedtls_base64_decode(reinterpret_cast(decoded.data()), - decoded_len, &actual_len, - reinterpret_cast(base64_creds.c_str()), - base64_creds.length()); - if (ret != 0) { - LOG_W(TAG, "Failed to decode base64 credentials"); - return sendUnauthorized(request, "Invalid credentials format"); - } - decoded.resize(actual_len); - - // Parse username:password - size_t colon_pos = decoded.find(':'); - if (colon_pos == std::string::npos) { - LOG_W(TAG, "Invalid credentials format (no colon separator)"); - return sendUnauthorized(request, "Invalid credentials format"); - } - - std::string username = decoded.substr(0, colon_pos); - std::string password = decoded.substr(colon_pos + 1); - - // Validate against cached settings - bool usernameMatch = secureCompare(username, settings.webServerUsername); - bool passwordMatch = secureCompare(password, settings.webServerPassword); - if (!usernameMatch || !passwordMatch) { - LOG_W(TAG, "Invalid credentials for user '%s'", username.c_str()); - return sendUnauthorized(request, "Invalid credentials"); - } - - authPassed = true; - return ERROR_NONE; // Auth successful -} - -bool WebServerService::onStart(ServiceContext& service) { - LOG_I(TAG, "Starting WebServer service..."); - - // Register global instance - g_webServerInstance.store(this); - - // Create statusbar icon (hidden initially, shown when server actually starts) - statusbarIconId = lvgl_statusbar_icon_add(nullptr, false); - lvgl_statusbar_icon_set_visibility(statusbarIconId, false); - - // Load and cache settings once at boot - bool serverEnabled; - { - auto lock = g_settingsMutex.asScopedLock(); - lock.lock(); - g_cachedSettings = settings::webserver::loadOrGetDefault(); - g_settingsCached = true; - serverEnabled = g_cachedSettings.webServerEnabled; - } - // Subscribe to settings change events to refresh cache - settingsEventSubscription = pubsub->subscribe([](WebServerEvent event) { - if (event == WebServerEvent::WebServerSettingsChanged) { - auto lock = g_settingsMutex.asScopedLock(); - lock.lock(); - g_cachedSettings = settings::webserver::loadOrGetDefault(); - g_settingsCached = true; - } - }); - - // Start HTTP server only if enabled in settings (default: OFF to save memory) - if (serverEnabled) { - LOG_I(TAG, "WebServer enabled in settings, starting HTTP server..."); - setEnabled(true); - } else { - LOG_I(TAG, "WebServer disabled in settings, NOT starting HTTP server (saves ~10KB RAM)"); - setEnabled(false); - } - - return true; -} - -void WebServerService::onStop(ServiceContext& service) { - g_webServerInstance.store(nullptr); - - pubsub->unsubscribe(settingsEventSubscription); - settingsEventSubscription = 0; - - setEnabled(false); - - // Remove statusbar icon - if (statusbarIconId >= 0) { - lvgl_statusbar_icon_remove(statusbarIconId); - statusbarIconId = -1; - } -} - -// region Enable/Disable - -void WebServerService::setEnabled(bool enabled) { - auto lock = mutex.asScopedLock(); - lock.lock(); - - if (enabled) { - if (httpServer == nullptr) { - startServer(); - } - } else { - if (httpServer != nullptr) { - stopServer(); - } - } -} - -bool WebServerService::isEnabled() const { - auto lock = mutex.asScopedLock(); - lock.lock(); - return httpServer != nullptr; -} - -// region AP Mode WiFi Management - -#ifdef ESP_PLATFORM - -bool WebServerService::startApMode() { - // Copy settings locally - settings::webserver::WebServerSettings settings; - { - auto lock = g_settingsMutex.asScopedLock(); - lock.lock(); - settings = g_cachedSettings; - } - - if (settings.wifiMode != settings::webserver::WiFiMode::AccessPoint) { - LOG_I(TAG, "Not in AP mode, skipping AP WiFi initialization"); - return true; // Not an error, just not needed - } - - LOG_I(TAG, "Starting WiFi in Access Point mode..."); - - // Acquires the shared radio (see esp32_wifi_radio.h): merges into WIFI_MODE_APSTA if Station - // mode already owns the radio, instead of calling esp_wifi_init()/start() ourselves and - // colliding with it. - if (!esp32_wifi_radio_acquire(WIFI_MODE_AP)) { - LOG_E(TAG, "Failed to acquire WiFi radio for AP mode"); - return false; - } - apWifiInitialized = true; - - // Create the AP network interface - apNetif = esp_netif_create_default_wifi_ap(); - if (apNetif == nullptr) { - LOG_E(TAG, "esp_netif_create_default_wifi_ap() failed"); - stopApMode(); - return false; - } - - // Configure static IP for AP: 192.168.4.1/24 - esp_netif_ip_info_t ip_info; - memset(&ip_info, 0, sizeof(esp_netif_ip_info_t)); - ip_info.ip.addr = ipaddr_addr("192.168.4.1"); - ip_info.gw.addr = ipaddr_addr("192.168.4.1"); - ip_info.netmask.addr = ipaddr_addr("255.255.255.0"); - - if (esp_netif_dhcps_stop(apNetif) != ESP_OK) { - LOG_E(TAG, "esp_netif_dhcps_stop() failed"); - stopApMode(); - return false; - } - - if (esp_netif_set_ip_info(apNetif, &ip_info) != ESP_OK) { - LOG_E(TAG, "esp_netif_set_ip_info() failed"); - stopApMode(); - return false; - } - - if (esp_netif_dhcps_start(apNetif) != ESP_OK) { - LOG_E(TAG, "esp_netif_dhcps_start() failed"); - stopApMode(); - return false; - } - - // Configure WiFi AP settings - wifi_config_t wifi_config; - memset(&wifi_config, 0, sizeof(wifi_config_t)); - - // Set SSID - strncpy(reinterpret_cast(wifi_config.ap.ssid), settings.apSsid.c_str(), sizeof(wifi_config.ap.ssid) - 1); - wifi_config.ap.ssid[sizeof(wifi_config.ap.ssid) - 1] = '\0'; - wifi_config.ap.ssid_len = static_cast(settings.apSsid.length()); - - // Set password and auth mode - if (settings.apOpenNetwork) { - // User explicitly chose an open network - wifi_config.ap.authmode = WIFI_AUTH_OPEN; - LOG_I(TAG, "AP configured with OPEN authentication (user choice)"); - } else if (settings.apPassword.length() >= 8 && settings.apPassword.length() <= 63) { - wifi_config.ap.authmode = WIFI_AUTH_WPA2_PSK; - strncpy(reinterpret_cast(wifi_config.ap.password), settings.apPassword.c_str(), sizeof(wifi_config.ap.password) - 1); - wifi_config.ap.password[sizeof(wifi_config.ap.password) - 1] = '\0'; - LOG_I(TAG, "AP configured with WPA2-PSK authentication"); - } else { - if (!settings.apPassword.empty()) { - LOG_W(TAG, "AP password invalid (must be 8-63 chars, got %zu) - using OPEN mode", settings.apPassword.length()); - } - wifi_config.ap.authmode = WIFI_AUTH_OPEN; - LOG_W(TAG, "AP configured with OPEN authentication (no password)"); - } - - wifi_config.ap.max_connection = 4; - wifi_config.ap.channel = settings.apChannel; - - if (esp_wifi_set_config(WIFI_IF_AP, &wifi_config) != ESP_OK) { - LOG_E(TAG, "esp_wifi_set_config(AP) failed"); - stopApMode(); - return false; - } - - LOG_I(TAG, "WiFi AP started - SSID: '%s', Channel: %u, IP: 192.168.4.1", settings.apSsid.c_str(), (unsigned)settings.apChannel); - return true; -} - -void WebServerService::stopApMode() { - if (apWifiInitialized) { - // Detach netif from the internal WiFi event handlers before releasing the radio, otherwise - // esp_netif_destroy() can race with esp_wifi_stop()'s own netif teardown (matches the same - // guard in esp32_wifi.cpp's bring_down_wifi()). - if (apNetif != nullptr) { - esp_wifi_clear_default_wifi_driver_and_handlers(apNetif); - } - esp32_wifi_radio_release(WIFI_MODE_AP); - LOG_I(TAG, "WiFi AP stopped"); - apWifiInitialized = false; - } - - if (apNetif != nullptr) { - esp_netif_destroy(apNetif); - apNetif = nullptr; - } -} - -#else - -bool WebServerService::startApMode() { - LOG_W(TAG, "AP mode WiFi is not supported on this platform"); - return false; -} - -void WebServerService::stopApMode() {} - -#endif // ESP_PLATFORM - -// endregion - -bool WebServerService::startServer() { - // Copy settings locally to minimize lock duration - settings::webserver::WebServerSettings settings; - { - auto lock = g_settingsMutex.asScopedLock(); - lock.lock(); - settings = g_cachedSettings; - } - - // Start AP mode WiFi if configured - if (settings.wifiMode == settings::webserver::WiFiMode::AccessPoint) { - if (!startApMode()) { - LOG_E(TAG, "Failed to start AP mode WiFi - HTTP server will not start"); - return false; - } - } - - // NOTE: If you see 'no slots left for registering handler', increase CONFIG_HTTPD_MAX_URI_HANDLERS in sdkconfig (default is 8, 16+ recommended for many endpoints) - void* ctx = this; // Avoid IDE warnings about 'this' in designated initializers - HttpServerRequestHandler handlers[] = { - { - .uri = "/", - .method = HTTP_METHOD_GET, - .callback = handleRoot, - .user_ctx = ctx - }, - // Note: /upload removed in favor of POST /fs/upload handled by /fs/* dispatcher - { - .uri = "/filebrowser", - .method = HTTP_METHOD_GET, - .callback = handleFileBrowser, - .user_ctx = ctx - }, - // Consolidated /fs/* handlers (dispatch internally) to save uri handler slots - { - .uri = "/fs/*", - .method = HTTP_METHOD_GET, - .callback = handleFsGenericGet, - .user_ctx = ctx - }, - { - .uri = "/fs/*", - .method = HTTP_METHOD_POST, - .callback = handleFsGenericPost, - .user_ctx = ctx - }, - // Consolidated admin POST endpoints to save handler slots - { - .uri = "/admin/*", - .method = HTTP_METHOD_POST, - .callback = handleAdminPost, - .user_ctx = ctx - }, - // API endpoints for system info, apps, wifi, etc - { - .uri = "/api/*", - .method = HTTP_METHOD_GET, - .callback = handleApiGet, - .user_ctx = ctx - }, - { - .uri = "/api/*", - .method = HTTP_METHOD_POST, - .callback = handleApiPost, - .user_ctx = ctx - }, - { - .uri = "/api/*", - .method = HTTP_METHOD_PUT, - .callback = handleApiPut, - .user_ctx = ctx - }, - { - .uri = "/*", // Catch-all for dynamic assets - .method = HTTP_METHOD_GET, - .callback = handleAssets, - .user_ctx = ctx - } - }; - - HttpServerConfig config { - .port = settings.webServerPort, - .address = "0.0.0.0", - .stack_size = 8192, - .handlers = handlers, - .handler_count = std::size(handlers), - }; - - httpServer = http_server_alloc(&config); - if (httpServer == nullptr || http_server_start(httpServer) != ERROR_NONE) { - LOG_E(TAG, "Failed to start HTTP server on port %u", (unsigned)settings.webServerPort); - http_server_free(httpServer); - httpServer = nullptr; - stopApMode(); - return false; - } - - LOG_I(TAG, "HTTP server started successfully on port %u", (unsigned)settings.webServerPort); - publish_event(this, WebServerEvent::WebServerStarted); - - // Show statusbar icon - if (statusbarIconId >= 0) { - lvgl_statusbar_icon_set_image(statusbarIconId, LVGL_ICON_STATUSBAR_CLOUD); - lvgl_statusbar_icon_set_visibility(statusbarIconId, true); - LOG_I(TAG, "WebServer statusbar icon shown (%s mode)", - settings.wifiMode == settings::webserver::WiFiMode::AccessPoint ? "AP" : "Station"); - } - - return true; -} - -void WebServerService::stopServer() { - if (httpServer == nullptr) { - return; - } - - http_server_free(httpServer); - httpServer = nullptr; - - // Stop AP mode WiFi if we started it - if (apWifiInitialized -#ifdef ESP_PLATFORM - || apNetif != nullptr -#endif - ) { - stopApMode(); - } - - LOG_I(TAG, "HTTP server stopped"); - publish_event(this, WebServerEvent::WebServerStopped); - - if (statusbarIconId >= 0) { - lvgl_statusbar_icon_set_visibility(statusbarIconId, false); - } -} - -WebServerService::~WebServerService() { - http_server_free(httpServer); -} - -// region Endpoints - -error_t WebServerService::handleRoot(HttpServerRequest* request, void*) { - LOG_I(TAG, "GET / -> redirecting to /dashboard.html"); - http_server_request_set_status(request, 302); - http_server_request_set_header(request, "Location", "/dashboard.html"); - return http_server_request_send(request, nullptr, 0); -} - -// region File Browser helpers & handlers - -// Helper to determine content type from file extension -static const char* getContentType(const std::string& path) { - // Check from the end to avoid matching extensions in directory names - auto endsWith = [&path](const char* ext) { - size_t extLen = strlen(ext); - return path.length() >= extLen && - path.compare(path.length() - extLen, extLen, ext) == 0; - }; - - // HTML/Text - if (endsWith(".html") || endsWith(".htm")) return "text/html"; - if (endsWith(".css")) return "text/css"; - if (endsWith(".js")) return "application/javascript"; - if (endsWith(".json")) return "application/json"; - if (endsWith(".xml")) return "application/xml"; - if (endsWith(".txt")) return "text/plain"; - - // Images - if (endsWith(".png")) return "image/png"; - if (endsWith(".jpg") || endsWith(".jpeg")) return "image/jpeg"; - if (endsWith(".gif")) return "image/gif"; - if (endsWith(".svg")) return "image/svg+xml"; - if (endsWith(".ico")) return "image/x-icon"; - if (endsWith(".webp")) return "image/webp"; - - // Fonts - if (endsWith(".woff")) return "font/woff"; - if (endsWith(".woff2")) return "font/woff2"; - if (endsWith(".ttf")) return "font/ttf"; - if (endsWith(".otf")) return "font/otf"; - if (endsWith(".eot")) return "application/vnd.ms-fontobject"; - - // Audio/Video - if (endsWith(".mp3")) return "audio/mpeg"; - if (endsWith(".wav")) return "audio/wav"; - if (endsWith(".ogg")) return "audio/ogg"; - if (endsWith(".mp4")) return "video/mp4"; - if (endsWith(".webm")) return "video/webm"; - - // Archives/Documents - if (endsWith(".pdf")) return "application/pdf"; - if (endsWith(".zip")) return "application/zip"; - if (endsWith(".gz")) return "application/gzip"; - - // Default - return "application/octet-stream"; -} - -static bool isAllowedBasePath(const std::string& path, bool allowRoot = false) { - if (path.empty()) return false; - // Check for ".." as a complete path component - if (path == ".." || path.starts_with("../") || - path.find("/../") != std::string::npos || path.ends_with("/..")) { - return false; - } - if (allowRoot && path == "/") return true; - return path.starts_with("/data") || path.starts_with("/sdcard"); -} - -// Normalize client-supplied path: URL-decode, trim quotes/control chars, ensure leading slash, collapse duplicate slashes -static std::string normalizePath(const std::string& raw) { - // Helper: hex to int - auto hexVal = [](char c)->int { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return 10 + (c - 'a'); - if (c >= 'A' && c <= 'F') return 10 + (c - 'A'); - return -1; - }; - - std::string s = raw; - // Remove surrounding single or double quotes - if (s.size() >= 2 && ((s.front() == '\'' && s.back() == '\'') || (s.front() == '"' && s.back() == '"'))) { - s = s.substr(1, s.size() - 2); - } - - // URL-decode: %xx and '+' -> ' ' - std::string decoded; - decoded.reserve(s.size()); - for (size_t i = 0; i < s.size(); ++i) { - char c = s[i]; - if (c == '%') { - if (i + 2 < s.size()) { - int hi = hexVal(s[i+1]); - int lo = hexVal(s[i+2]); - if (hi >= 0 && lo >= 0) { - decoded.push_back(static_cast((hi << 4) | lo)); - i += 2; - continue; - } - } - // malformed %, keep it - decoded.push_back(c); - } else if (c == '+') { - decoded.push_back(' '); - } else { - // strip control characters - if (static_cast(c) > 31) decoded.push_back(c); - } - } - - // Trim whitespace from ends - size_t start = 0; - while (start < decoded.size() && isspace((unsigned char)decoded[start])) ++start; - size_t end = decoded.size(); - while (end > start && isspace((unsigned char)decoded[end-1])) --end; - std::string trimmed = decoded.substr(start, end - start); - - // Ensure leading slash - if (!trimmed.empty() && trimmed.front() != '/') trimmed = '/' + trimmed; - if (trimmed.empty()) trimmed = "/"; - - // Collapse duplicate slashes - std::string out; - out.reserve(trimmed.size()); - bool lastSlash = false; - for (char c : trimmed) { - if (c == '/') { - if (!lastSlash) { out.push_back(c); lastSlash = true; } - } else { out.push_back(c); lastSlash = false; } - } - - return out; -} - -static std::string escapeJson(const std::string& s) { - std::ostringstream o; - for (char c : s) { - switch (c) { - case '"': o << "\\\""; break; - case '\\': o << "\\\\"; break; - case '\n': o << "\\n"; break; - case '\r': o << "\\r"; break; - case '\t': o << "\\t"; break; - default: - if (static_cast(c) < 0x20) { - o << "\\u" << std::hex << std::setw(4) << std::setfill('0') << (int)c; - } else { - o << c; - } - } - } - return o.str(); -} - -// Raw (not URL-decoded) extraction, matching ESP-IDF's httpd_query_key_value() semantics - -// callers that need decoding (e.g. normalizePath()) already do it themselves on the raw value. -static bool getQueryParam(HttpServerRequest* request, const char* key, std::string& out) { - size_t length = http_server_request_get_query(request, nullptr, 0); - if (length == 0) { - return false; - } - std::string query(length, '\0'); - http_server_request_get_query(request, query.data(), length + 1); - - size_t pos = 0; - while (pos < query.size()) { - size_t amp = query.find('&', pos); - size_t pair_end = amp == std::string::npos ? query.size() : amp; - size_t eq = query.find('=', pos); - if (eq != std::string::npos && eq < pair_end && query.compare(pos, eq - pos, key) == 0) { - out = query.substr(eq + 1, pair_end - eq - 1); - return true; - } - if (amp == std::string::npos) { - break; - } - pos = amp + 1; - } - return false; -} - -static bool uriMatches(const char* uri, const char* route) { - const size_t n = strlen(route); - return strncmp(uri, route, n) == 0 && (uri[n] == '\0' || uri[n] == '?' || uri[n] == '/'); -} - -error_t WebServerService::handleFileBrowser(HttpServerRequest* request, void*) { - LOG_I(TAG, "GET /filebrowser -> redirecting to /dashboard.html#files"); - http_server_request_set_status(request, 302); - http_server_request_set_header(request, "Location", "/dashboard.html#files"); - return http_server_request_send(request, nullptr, 0); -} - -error_t WebServerService::handleFsList(HttpServerRequest* request, void*) { - std::string path; - // Log raw query string for diagnostics - char qbuf[256]; - if (http_server_request_get_query(request, qbuf, sizeof(qbuf)) > 0) { - LOG_I(TAG, "GET /fs/list raw query: %s", qbuf); - } - - if (!getQueryParam(request, "path", path) || path.empty()) path = "/"; - std::string norm = normalizePath(path); - LOG_I(TAG, "GET /fs/list decoded path: '%s' normalized: '%s'", path.c_str(), norm.c_str()); - - // Allow root path for listing mount points - if (!isAllowedBasePath(norm, true)) { - LOG_W(TAG, "GET /fs/list - invalid path requested: '%s' normalized: '%s'", path.c_str(), norm.c_str()); - http_server_request_set_content_type(request, "application/json"); - http_server_request_send_string(request, "{\"error\":\"invalid path\"}"); - return ERROR_NONE; - } - - std::ostringstream json; - json << "{\"path\":\"" << norm << "\",\"entries\":["; - struct FsIterContext { - std::ostringstream& json; - uint16_t count = 0; - }; - FsIterContext fs_iter_context { json }; - // Special handling for root: show available mount points - if (norm == "/") { - file_system_for_each(&fs_iter_context, [] (auto* fs, void* context) { - auto* fs_iter_context = static_cast(context); - char path[128]; - if (file_system_is_mounted(fs) && file_system_get_path(fs, path, sizeof(path)) == ERROR_NONE) { - fs_iter_context->count++; - if (fs_iter_context->count != 1) fs_iter_context->json << ","; // add separator between json array entries - fs_iter_context->json << "{\"name\":\"" << path << "\",\"type\":\"dir\",\"size\":0}"; - } - return true; - }); - - json << "]}"; - } else { - std::vector entries; - int res = file::scandir(norm, entries, file::direntFilterDotEntries, nullptr); - if (res < 0) { - http_server_request_set_content_type(request, "application/json"); - http_server_request_send_string(request, "{\"error\":\"scan failed\"}"); - return ERROR_NONE; - } - bool first = true; - for (auto& e : entries) { - if (!first) json << ','; else first = false; - std::string name = e.d_name; - bool is_dir = (e.d_type == file::TT_DT_DIR || e.d_type == file::TT_DT_CHR); - std::string full = norm + "/" + name; - long size = 0; - if (!is_dir) { - struct stat st; - if (stat(full.c_str(), &st) == 0) { - size = st.st_size; - } - } - json << "{\"name\":\"" << escapeJson(name) << "\",\"type\":\"" << (is_dir?"dir":"file") << "\",\"size\":" << size << "}"; - } - json << "]}"; - } - - http_server_request_set_content_type(request, "application/json"); - http_server_request_send_string(request, json.str().c_str()); - return ERROR_NONE; -} - -error_t WebServerService::handleFsDownload(HttpServerRequest* request, void*) { - std::string path; - if (!getQueryParam(request, "path", path) || path.empty()) { - http_server_request_send_error(request, 400, "path required"); - return ERROR_UNDEFINED; - } - std::string norm = normalizePath(path); - if (!isAllowedBasePath(norm) || !file::isFile(norm)) { - LOG_W(TAG, "GET /fs/download - not found or invalid path: '%s' normalized: '%s'", path.c_str(), norm.c_str()); - http_server_request_send_error(request, 404, "not found"); - return ERROR_UNDEFINED; - } - http_server_request_set_content_type(request, getContentType(norm)); - // Suggest download - build header into a local string so it remains valid - std::string fname = file::getLastPathSegment(norm); - std::string disposition = std::string("attachment; filename=\"") + fname + "\""; - // RFC5987 fallback (filename*): percent-encode UTF-8 bytes for wider browser compatibility - auto pctEncode = [](const std::string& s)->std::string{ - std::ostringstream oss; - for (unsigned char c : s) { - if (std::isalnum(c) || c=='-' || c=='.' || c=='_' || c=='~') { - oss << c; - } else { - oss << '%'; - std::ostringstream hex; - hex << std::uppercase << std::hex << std::setw(2) << std::setfill('0') << (int)c; - oss << hex.str(); - } - } - return oss.str(); - }; - std::string pct = pctEncode(fname); - if (!pct.empty()) { - disposition += std::string("; filename*=UTF-8''") + pct; - } - // Set single Content-Disposition header (avoid adding duplicate headers) - http_server_request_set_header(request, "Content-Disposition", disposition.c_str()); - FILE* fp = fopen(norm.c_str(), "rb"); - if (!fp) { http_server_request_send_error(request, 500, "open failed"); return ERROR_UNDEFINED; } - if (http_server_request_send_chunk_start(request) != ERROR_NONE) { fclose(fp); return ERROR_UNDEFINED; } - char buf[512]; size_t n; - while ((n = fread(buf,1,sizeof(buf),fp))>0) { - if (http_server_request_send_chunk(request, buf, n) != ERROR_NONE) { fclose(fp); return ERROR_UNDEFINED; } - } - fclose(fp); - http_server_request_send_chunk_end(request); - return ERROR_NONE; -} - -error_t WebServerService::handleFsUpload(HttpServerRequest* request, void*) { - std::string path; - - // Log raw query and decoded path for diagnostics - char qbuf[256]; - if (http_server_request_get_query(request, qbuf, sizeof(qbuf)) > 0) { - LOG_I(TAG, "POST /fs/upload raw query: %s", qbuf); - } - - if (!getQueryParam(request, "path", path) || path.empty()) { - http_server_request_send_error(request, 400, "path required"); - return ERROR_UNDEFINED; - } - - // Log decoded path and headers - char content_type[64] = {0}; - http_server_request_get_header(request, "Content-Type", content_type, sizeof(content_type)); - std::string norm = normalizePath(path); - uint64_t content_length = http_server_request_get_content_length(request); - LOG_I(TAG, "POST /fs/upload decoded path: '%s' normalized: '%s' Content-Length: %d Content-Type: %s", path.c_str(), norm.c_str(), (int)content_length, content_type[0] ? content_type : "(null)"); - - if (!isAllowedBasePath(norm)) { - LOG_W(TAG, "POST /fs/upload - invalid path requested: '%s' normalized: '%s'", path.c_str(), norm.c_str()); - http_server_request_send_error(request, 403, "invalid path"); - return ERROR_UNDEFINED; - } - - if (content_length > MAX_UPLOAD_SIZE) { - http_server_request_send_error(request, 400, "file too large"); - return ERROR_UNDEFINED; - } - - // Ensure parent directory exists (after size check to avoid creating dirs for rejected uploads) - if (!file::findOrCreateParentDirectory(norm, 0755)) { - http_server_request_send_error(request, 500, "failed to create parent directory"); - return ERROR_UNDEFINED; - } - FILE* fp = fopen(norm.c_str(), "wb"); - if (!fp) { http_server_request_send_error(request, 500, "open failed"); return ERROR_UNDEFINED; } - char buf[512]; int remaining = static_cast(content_length); int received=0; - while (remaining > 0) { - int to_read = remaining > (int)sizeof(buf) ? (int)sizeof(buf) : remaining; - int ret = http_server_request_receive(request, buf, to_read); - if (ret <= 0) { - LOG_E(TAG, "Upload recv failed with error %d", ret); - fclose(fp); - remove(norm.c_str()); // Clean up partial file - http_server_request_send_error(request, 500, "recv failed"); - return ERROR_UNDEFINED; - } - size_t written = fwrite(buf, 1, ret, fp); - if (written != (size_t)ret) { - fclose(fp); - remove(norm.c_str()); - http_server_request_send_error(request, 500, "write failed"); - return ERROR_UNDEFINED; - } - remaining -= ret; - received += ret; - } - fclose(fp); - http_server_request_set_content_type(request, "text/plain"); - std::string msg = std::string("Uploaded ") + std::to_string(received) + " bytes"; - http_server_request_send_string(request, msg.c_str()); - return ERROR_NONE; -} - -// Generic GET dispatcher for /fs/* URIs -error_t WebServerService::handleFsGenericGet(HttpServerRequest* request, void* user_ctx) { - // Auth check for all /fs/* endpoints (file system access is sensitive) - bool authPassed = false; - error_t authResult = validateRequestAuth(request, authPassed); - if (!authPassed) { - return authResult; - } - - char uri[256]; - http_server_request_get_uri(request, uri, sizeof(uri)); - if (uriMatches(uri, "/fs/list")) return handleFsList(request, user_ctx); - if (uriMatches(uri, "/fs/download")) return handleFsDownload(request, user_ctx); - if (uriMatches(uri, "/fs/tree")) return handleFsTree(request, user_ctx); - LOG_W(TAG, "GET %s - not found in fs generic dispatcher", uri); - http_server_request_send_error(request, 404, "not found"); - return ERROR_UNDEFINED; -} - -// Generic POST dispatcher for /fs/* URIs -error_t WebServerService::handleFsGenericPost(HttpServerRequest* request, void* user_ctx) { - // Auth check for all /fs/* endpoints (file system access is sensitive) - bool authPassed = false; - error_t authResult = validateRequestAuth(request, authPassed); - if (!authPassed) { - return authResult; - } - - char uri[256]; - http_server_request_get_uri(request, uri, sizeof(uri)); - if (uriMatches(uri, "/fs/mkdir")) return handleFsMkdir(request, user_ctx); - if (uriMatches(uri, "/fs/delete")) return handleFsDelete(request, user_ctx); - if (uriMatches(uri, "/fs/rename")) return handleFsRename(request, user_ctx); - if (uriMatches(uri, "/fs/upload")) return handleFsUpload(request, user_ctx); - LOG_W(TAG, "POST %s - not found in fs generic dispatcher", uri); - http_server_request_send_error(request, 404, "not found"); - return ERROR_UNDEFINED; -} - -// Admin dispatcher for consolidated small POST endpoints (e.g. sync, reboot) -error_t WebServerService::handleAdminPost(HttpServerRequest* request, void* user_ctx) { - // Auth check for all /admin/* endpoints (admin actions are sensitive) - bool authPassed = false; - error_t authResult = validateRequestAuth(request, authPassed); - if (!authPassed) { - return authResult; - } - - char uri[256]; - http_server_request_get_uri(request, uri, sizeof(uri)); - if (strncmp(uri, "/admin/reboot", 13) == 0) return handleReboot(request, user_ctx); - LOG_I(TAG, "POST %s - not found in admin dispatcher", uri); - http_server_request_send_error(request, 404, "not found"); - return ERROR_UNDEFINED; -} - -// API GET dispatcher - returns JSON system information -// Note: /api/sysinfo is intentionally public for monitoring use cases -error_t WebServerService::handleApiGet(HttpServerRequest* request, void* user_ctx) { - char uri[256]; - http_server_request_get_uri(request, uri, sizeof(uri)); - - // Public endpoint: sysinfo (basic device info for monitoring) - if (strncmp(uri, "/api/sysinfo", 12) == 0) { - return handleApiSysinfo(request, user_ctx); - } - - // Protected endpoints require authentication - bool authPassed = false; - error_t authResult = validateRequestAuth(request, authPassed); - if (!authPassed) { - return authResult; - } - - // Auth-protected endpoints - if (strncmp(uri, "/api/apps", 9) == 0) { - return handleApiApps(request, user_ctx); - } - if (strncmp(uri, "/api/wifi", 9) == 0) { - return handleApiWifi(request, user_ctx); - } - if (strncmp(uri, "/api/screenshot", 15) == 0) { - return handleApiScreenshot(request, user_ctx); - } - - LOG_W(TAG, "GET %s - not found in api dispatcher", uri); - http_server_request_send_error(request, 404, "not found"); - return ERROR_UNDEFINED; -} - -// API POST dispatcher - all POST endpoints require authentication -error_t WebServerService::handleApiPost(HttpServerRequest* request, void* user_ctx) { - bool authPassed = false; - error_t authResult = validateRequestAuth(request, authPassed); - if (!authPassed) { - return authResult; - } - - char uri[256]; - http_server_request_get_uri(request, uri, sizeof(uri)); - if (strncmp(uri, "/api/apps/run", 13) == 0) { - return handleApiAppsRun(request, user_ctx); - } - if (strncmp(uri, "/api/apps/uninstall", 19) == 0) { - return handleApiAppsUninstall(request, user_ctx); - } - - LOG_W(TAG, "POST %s - not found in api dispatcher", uri); - http_server_request_send_error(request, 404, "not found"); - return ERROR_UNDEFINED; -} - -// API PUT dispatcher - all PUT endpoints require authentication -error_t WebServerService::handleApiPut(HttpServerRequest* request, void* user_ctx) { - bool authPassed = false; - error_t authResult = validateRequestAuth(request, authPassed); - if (!authPassed) { - return authResult; - } - - char uri[256]; - http_server_request_get_uri(request, uri, sizeof(uri)); - if (strncmp(uri, "/api/apps/install", 17) == 0) { - return handleApiAppsInstall(request, user_ctx); - } - - LOG_W(TAG, "PUT %s - not found in api dispatcher", uri); - http_server_request_send_error(request, 404, "not found"); - return ERROR_UNDEFINED; -} - -error_t WebServerService::handleApiSysinfo(HttpServerRequest* request, void*) { - LOG_I(TAG, "GET /api/sysinfo"); - - std::ostringstream json; - json << "{"; - - // Firmware info - json << "\"firmware\":{"; - json << "\"version\":\"" << TT_VERSION << "\","; -#ifdef ESP_PLATFORM - json << "\"idf_version\":\"" << ESP_IDF_VERSION_MAJOR << "." << ESP_IDF_VERSION_MINOR << "." << ESP_IDF_VERSION_PATCH << "\""; -#else - json << "\"idf_version\":\"n/a\""; -#endif - json << "},"; - - // Chip info - json << "\"chip\":{"; -#ifdef ESP_PLATFORM - esp_chip_info_t chip_info; - esp_chip_info(&chip_info); - json << "\"model\":\"" << getChipModelName(chip_info.model) << "\","; - json << "\"cores\":" << (int)chip_info.cores << ","; - json << "\"revision\":" << (int)chip_info.revision << ","; - - // Decode features into an array of strings - json << "\"features\":["; - bool first_feature = true; - if (chip_info.features & CHIP_FEATURE_EMB_FLASH) { - json << "\"Embedded Flash\""; - first_feature = false; - } - if (chip_info.features & CHIP_FEATURE_WIFI_BGN) { - if (!first_feature) json << ","; - json << "\"WiFi 2.4GHz\""; - first_feature = false; - } - if (chip_info.features & CHIP_FEATURE_BLE) { - if (!first_feature) json << ","; - json << "\"BLE\""; - first_feature = false; - } - if (chip_info.features & CHIP_FEATURE_BT) { - if (!first_feature) json << ","; - json << "\"Bluetooth Classic\""; - first_feature = false; - } - if (chip_info.features & CHIP_FEATURE_IEEE802154) { - if (!first_feature) json << ","; - json << "\"IEEE 802.15.4\""; - first_feature = false; - } - if (chip_info.features & CHIP_FEATURE_EMB_PSRAM) { - if (!first_feature) json << ","; - json << "\"Embedded PSRAM\""; - } - json << "],"; - - // Internal flash size - uint32_t flash_size = 0; - esp_flash_get_size(nullptr, &flash_size); - json << "\"flash_size\":" << flash_size; -#else - json << "\"model\":\"" << CONFIG_TT_DEVICE_ID << "\","; - json << "\"cores\":0,"; - json << "\"revision\":0,"; - json << "\"features\":[],"; - json << "\"flash_size\":0"; -#endif - json << "},"; - - // Memory - Internal heap - json << "\"heap\":{"; -#ifdef ESP_PLATFORM - size_t heap_free = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); - size_t heap_total = heap_caps_get_total_size(MALLOC_CAP_INTERNAL); - size_t heap_min_free = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL); - size_t heap_largest = heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL); - - json << "\"free\":" << heap_free << ","; - json << "\"total\":" << heap_total << ","; - json << "\"min_free\":" << heap_min_free << ","; - json << "\"largest_block\":" << heap_largest; -#else - json << "\"free\":0,\"total\":0,\"min_free\":0,\"largest_block\":0"; -#endif - json << "},"; - - // Memory - PSRAM (external) - json << "\"psram\":{"; -#ifdef ESP_PLATFORM - size_t psram_free = heap_caps_get_free_size(MALLOC_CAP_SPIRAM); - size_t psram_total = heap_caps_get_total_size(MALLOC_CAP_SPIRAM); - size_t psram_min_free = heap_caps_get_minimum_free_size(MALLOC_CAP_SPIRAM); - size_t psram_largest = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM); - - json << "\"free\":" << psram_free << ","; - json << "\"total\":" << psram_total << ","; - json << "\"min_free\":" << psram_min_free << ","; - json << "\"largest_block\":" << psram_largest; -#else - json << "\"free\":0,\"total\":0,\"min_free\":0,\"largest_block\":0"; -#endif - json << "},"; - - // Storage info - json << "\"storage\":{"; - - struct FsIterContext { - std::ostringstream& json; - uint16_t count = 0; - }; - FsIterContext fs_iter_context { json }; - file_system_for_each(&fs_iter_context, [] (auto* fs, void* context) { - char mount_path[128] = ""; - if (file_system_get_path(fs, mount_path, sizeof(mount_path)) != ERROR_NONE) return true; - - bool mounted = file_system_is_mounted(fs); - auto* fs_iter_context = static_cast(context); - auto& json_context = fs_iter_context->json; - std::string mount_path_cpp = mount_path; - - fs_iter_context->count++; - if (fs_iter_context->count != 1) json_context << ","; // add separator between json array entries - json_context << "\"" << mount_path_cpp.substr(1) << "\":{"; - -#ifdef ESP_PLATFORM - uint64_t storage_total = 0, storage_free = 0; - if (esp_vfs_fat_info(mount_path, &storage_total, &storage_free) == ESP_OK) { - json_context << "\"free\":" << storage_free << ","; - json_context << "\"total\":" << storage_total << ","; - } else { - json_context << "\"free\":0,"; - json_context << "\"total\":0,"; - } -#else - json_context << "\"free\":0,"; - json_context << "\"total\":0,"; -#endif - - json_context << "\"mounted\":" << (mounted ? "true" : "false") << ""; - json_context << "}"; - return true; - }); - - json << "},"; // end storage - - // Uptime (in seconds) - TickType_t ticks = xTaskGetTickCount(); - float uptime_sec = static_cast(ticks) / configTICK_RATE_HZ; - json << "\"uptime\":" << static_cast(uptime_sec) << ","; - - // Task count - UBaseType_t task_count = uxTaskGetNumberOfTasks(); - json << "\"task_count\":" << task_count << ","; - - // Feature flags - json << "\"features_enabled\":{"; -#if TT_FEATURE_SCREENSHOT_ENABLED - json << "\"screenshot\":true"; -#else - json << "\"screenshot\":false"; -#endif - json << "}"; - - json << "}"; - - http_server_request_set_content_type(request, "application/json"); - http_server_request_send_string(request, json.str().c_str()); - return ERROR_NONE; -} - -// GET /api/apps - List installed apps -error_t WebServerService::handleApiApps(HttpServerRequest* request, void*) { - LOG_I(TAG, "GET /api/apps"); - - std::vector manifests; - app_manager_for_each_manifest([](const ::AppManifest* manifest, void* context) { - static_cast*>(context)->push_back(manifest); - }, &manifests); - - std::ostringstream json; - json << "{\"apps\":["; - - bool first = true; - for (const auto* manifest : manifests) { - if (!first) json << ","; - first = false; - - json << "{"; - json << "\"id\":\"" << escapeJson(manifest->id) << "\","; - json << "\"name\":\"" << escapeJson(manifest->name) << "\","; - - const char* category = "user"; - if (manifest->category == APP_CATEGORY_SYSTEM) category = "system"; - else if (manifest->category == APP_CATEGORY_SETTINGS) category = "settings"; - json << "\"category\":\"" << category << "\","; - - json << "\"isExternal\":" << (manifest->location.type == APP_LOCATION_PATH ? "true" : "false") << ","; - json << "\"hidden\":" << ((manifest->flags & APP_MANIFEST_FLAG_HIDDEN) ? "true" : "false"); - - json << "}"; - } - - json << "]}"; - - http_server_request_set_content_type(request, "application/json"); - http_server_request_send_string(request, json.str().c_str()); - return ERROR_NONE; -} - -// POST /api/apps/run?id=xxx - Run an app -error_t WebServerService::handleApiAppsRun(HttpServerRequest* request, void*) { - LOG_I(TAG, "POST /api/apps/run"); - - std::string appId; - if (!getQueryParam(request, "id", appId) || appId.empty()) { - http_server_request_send_error(request, 400, "id parameter required"); - return ERROR_UNDEFINED; - } - - AppStartContext context; - if (app_start_context_from_id(appId.c_str(), &context) != ERROR_NONE) { - http_server_request_send_error(request, 404, "app not found"); - return ERROR_UNDEFINED; - } - - // 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_start_with_context(&context, &instance_id); - - LOG_I(TAG, "[200] /api/apps/run %s", appId.c_str()); - http_server_request_send_string(request, "ok"); - return ERROR_NONE; -} - -// POST /api/apps/uninstall?id=xxx - Uninstall an app -error_t WebServerService::handleApiAppsUninstall(HttpServerRequest* request, void*) { - LOG_I(TAG, "POST /api/apps/uninstall"); - - std::string appId; - if (!getQueryParam(request, "id", appId) || appId.empty()) { - http_server_request_send_error(request, 400, "id parameter required"); - return ERROR_UNDEFINED; - } - - AppManifest manifest; - if (app_manager_find_manifest(appId.c_str(), &manifest) != ERROR_NONE) { - LOG_I(TAG, "[200] /api/apps/uninstall %s (app wasn't installed)", appId.c_str()); - http_server_request_send_string(request, "ok"); - return ERROR_NONE; - } - - // Only allow uninstalling external (side-loaded) apps - if (manifest.location.type != APP_LOCATION_PATH) { - http_server_request_send_error(request, 403, "cannot uninstall system apps"); - return ERROR_UNDEFINED; - } - - if (app_uninstall(appId.c_str()) == ERROR_NONE) { - LOG_I(TAG, "[200] /api/apps/uninstall %s", appId.c_str()); - http_server_request_send_string(request, "ok"); - return ERROR_NONE; - } else { - LOG_W(TAG, "[500] /api/apps/uninstall %s", appId.c_str()); - http_server_request_send_error(request, 500, "uninstall failed"); - return ERROR_UNDEFINED; - } -} - -// PUT /api/apps/install - Install an app from multipart form upload -error_t WebServerService::handleApiAppsInstall(HttpServerRequest* request, void*) { - LOG_I(TAG, "PUT /api/apps/install"); - - std::string boundary; - if (!network::getMultiPartBoundaryOrSendError(request, boundary)) { - return ERROR_UNDEFINED; - } - - size_t content_left = http_server_request_get_content_length(request); - constexpr size_t MAX_APP_UPLOAD_SIZE = 20 * 1024 * 1024; - - // Read headers until empty line (skip boundary line first) - auto content_headers_data = network::receiveTextUntil(request, "\r\n\r\n"); - content_left -= content_headers_data.length(); - - // Split headers into lines and filter empty ones - auto content_header_lines = string::split(content_headers_data, "\r\n"); - std::vector content_headers; - for (auto& line : content_header_lines) { - if (!line.empty()) { - content_headers.push_back(line); - } - } - - auto content_disposition_map = network::parseContentDisposition(content_headers); - if (content_disposition_map.empty()) { - LOG_W(TAG, "parseContentDisposition returned empty map for: %s", content_headers_data.c_str()); - http_server_request_send_error(request, 400, "invalid content disposition"); - return ERROR_UNDEFINED; - } - - auto filename_entry = content_disposition_map.find("filename"); - if (filename_entry == content_disposition_map.end()) { - LOG_W(TAG, "filename not found in content disposition map"); - http_server_request_send_error(request, 400, "filename parameter missing"); - return ERROR_UNDEFINED; - } - - // Calculate file size - auto boundary_and_newlines_after_file = std::format("\r\n--{}--\r\n", boundary); - if (content_left <= boundary_and_newlines_after_file.length()) { - http_server_request_send_error(request, 400, "invalid multipart payload"); - return ERROR_UNDEFINED; - } - - auto file_size = content_left - boundary_and_newlines_after_file.length(); - if (file_size == 0 || file_size > MAX_APP_UPLOAD_SIZE) { - http_server_request_send_error(request, 400, "file too large"); - return ERROR_UNDEFINED; - } - - // Create tmp directory - const std::string tmp_path = getTempPath(); - if (!file::findOrCreateDirectory(tmp_path, 0777)) { - http_server_request_send_error(request, 500, "failed to create temp directory"); - return ERROR_UNDEFINED; - } - - std::string safe_name = file::getLastPathSegment(filename_entry->second); - if (safe_name.empty() || safe_name.find("..") != std::string::npos || - safe_name.find('/') != std::string::npos || safe_name.find('\\') != std::string::npos) { - http_server_request_send_error(request, 400, "invalid filename"); - return ERROR_UNDEFINED; - } - auto file_path = std::format("{}/{}", tmp_path, safe_name); - - if (network::receiveFile(request, file_size, file_path) != file_size) { - file::deleteFile(file_path); - http_server_request_send_error(request, 500, "failed to save file"); - return ERROR_UNDEFINED; - } - - content_left -= file_size; - - // Read and discard trailing boundary - if (!network::readAndDiscardOrSendError(request, boundary_and_newlines_after_file)) { - return ERROR_UNDEFINED; - } - - // Install the app - if (app_install(file_path.c_str()) != ERROR_NONE) { - file::deleteFile(file_path); - http_server_request_send_error(request, 500, "installation failed"); - return ERROR_UNDEFINED; - } - - // Cleanup temp file - if (!file::deleteFile(file_path)) { - LOG_W(TAG, "Failed to delete temp file %s", file_path.c_str()); - } - - LOG_I(TAG, "[200] /api/apps/install -> %s", file_path.c_str()); - http_server_request_send_string(request, "ok"); - return ERROR_NONE; -} - -// Helper to convert radio state to string -static const char* radioStateToJsonString(WifiRadioState radioState, WifiStationState stationState) { - switch (radioState) { - case WIFI_RADIO_STATE_ON: - switch (stationState) { - case WIFI_STATION_STATE_CONNECTION_PENDING: return "connecting"; - case WIFI_STATION_STATE_CONNECTED: return "connected"; - default: return "on"; - } - case WIFI_RADIO_STATE_ON_PENDING: return "turning_on"; - case WIFI_RADIO_STATE_OFF: return "off"; - case WIFI_RADIO_STATE_OFF_PENDING: return "turning_off"; - default: return "unknown"; - } -} - -// GET /api/wifi - WiFi status -error_t WebServerService::handleApiWifi(HttpServerRequest* request, void*) { - LOG_I(TAG, "GET /api/wifi"); - - WifiRadioState radio_state = WIFI_RADIO_STATE_OFF; - WifiStationState station_state = WIFI_STATION_STATE_DISCONNECTED; - char ip[16] = {}; - char ssid[33] = {}; - int32_t rssi = 1; - bool secure = false; - - Device* wifi_device = nullptr; - if (device_get_first_by_type(&WIFI_TYPE, &wifi_device) == ERROR_NONE) { - wifi_get_radio_state(wifi_device, &radio_state); - wifi_get_station_state(wifi_device, &station_state); - if (station_state != WIFI_STATION_STATE_DISCONNECTED) { - wifi_station_get_target_ssid(wifi_device, ssid); - } - if (station_state == WIFI_STATION_STATE_CONNECTED) { - wifi_station_get_ipv4_address(wifi_device, ip); - wifi_station_get_rssi(wifi_device, &rssi); - // The driver doesn't report the active connection's authentication, so derive it from the last scan - WifiApRecord records[16]; - size_t record_count = std::size(records); - if (wifi_get_scan_results(wifi_device, records, &record_count) == ERROR_NONE) { - for (size_t i = 0; i < record_count; ++i) { - if (strcmp(records[i].ssid, ssid) == 0) { - secure = records[i].authentication_type != WIFI_AUTHENTICATION_TYPE_OPEN; - break; - } - } - } - } - device_put(wifi_device); - } - - std::ostringstream json; - json << "{"; - json << "\"state\":\"" << radioStateToJsonString(radio_state, station_state) << "\","; - json << "\"ip\":\"" << escapeJson(ip) << "\","; - json << "\"ssid\":\"" << escapeJson(ssid) << "\","; - json << "\"rssi\":" << rssi << ","; - json << "\"secure\":" << (secure ? "true" : "false"); - json << "}"; - - http_server_request_set_content_type(request, "application/json"); - http_server_request_send_string(request, json.str().c_str()); - return ERROR_NONE; -} - -// GET /api/screenshot - Capture and return screenshot as PNG -// Screenshots are saved to SD card root (if available) or /data with incrementing numbers -error_t WebServerService::handleApiScreenshot(HttpServerRequest* request, void*) { - LOG_I(TAG, "GET /api/screenshot"); - -#if TT_FEATURE_SCREENSHOT_ENABLED - // Determine save location: prefer SD card root if mounted, otherwise /data - std::string save_path = getDataPath(); - - // Find next available filename with incrementing number - std::string screenshot_path; - bool found_slot = false; - for (int i = 1; i <= 9999; ++i) { - screenshot_path = std::format("{}/webscreenshot{}.png", save_path, i); - if (!file::isFile(screenshot_path)) { - found_slot = true; - break; - } - } - if (!found_slot) { - http_server_request_send_error(request, 500, "no available screenshot slots"); - return ERROR_UNDEFINED; - } - - LOG_I(TAG, "Screenshot will be saved to: %s", screenshot_path.c_str()); - - // lv_screenshot writes through lv_fs, which requires the "A:" prefix - std::string lvgl_screenshot_path = lvgl::PATH_PREFIX + screenshot_path; - - // Capture screenshot using LVGL - if (lvgl_try_lock(pdMS_TO_TICKS(100))) { - bool success = lv_screenshot_create(lv_scr_act(), LV_100ASK_SCREENSHOT_SV_PNG, lvgl_screenshot_path.c_str()); - lvgl_unlock(); - - if (!success) { - LOG_E(TAG, "lv_screenshot_create failed for path: %s", lvgl_screenshot_path.c_str()); - http_server_request_send_error(request, 500, "screenshot capture failed"); - return ERROR_UNDEFINED; - } - LOG_I(TAG, "Screenshot captured successfully"); - } else { - LOG_E(TAG, "Could not acquire LVGL lock within 100ms"); - http_server_request_send_error(request, 500, "could not acquire LVGL lock"); - return ERROR_UNDEFINED; - } - - // Send the file (use regular path for fopen, not LVGL path) - http_server_request_set_content_type(request, "image/png"); - - FILE* fp = fopen(screenshot_path.c_str(), "rb"); - if (!fp) { - http_server_request_send_error(request, 500, "failed to open screenshot"); - return ERROR_UNDEFINED; - } - - if (http_server_request_send_chunk_start(request) != ERROR_NONE) { - fclose(fp); - return ERROR_UNDEFINED; - } - char buf[512]; - size_t n; - while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { - if (http_server_request_send_chunk(request, buf, n) != ERROR_NONE) { - fclose(fp); - return ERROR_UNDEFINED; - } - } - fclose(fp); - http_server_request_send_chunk_end(request); - - // File is kept on storage (not deleted) for user access - LOG_I(TAG, "[200] /api/screenshot -> %s", screenshot_path.c_str()); - return ERROR_NONE; -#else - http_server_request_send_error(request, 501, "screenshot feature not enabled"); - return ERROR_UNDEFINED; -#endif -} - -error_t WebServerService::handleFsTree(HttpServerRequest* request, void*) { - - LOG_I(TAG, "GET /fs/tree"); - - std::ostringstream json; - json << "{"; - // Gather mount points - auto mounts = file::getFileSystemDirents(); - json << "\"mounts\": ["; - bool firstMount = true; - for (auto& m : mounts) { - if (!firstMount) json << ','; else firstMount = false; - std::string name = m.d_name; - std::string path = (name == std::string("data") || name == std::string("/data")) ? std::string("/data") : std::string("/") + name; - // normalize possible duplicate slash - if (!path.starts_with("/")) path = std::string("/") + path; - json << "{\"name\":\"" << escapeJson(name) << "\",\"path\":\"" << escapeJson(path) << "\",\"entries\": ["; - - std::vector entries; - int res = file::scandir(path, entries, file::direntFilterDotEntries, nullptr); - if (res > 0) { - bool first = true; - for (auto& e : entries) { - if (!first) json << ','; else first = false; - std::string en = e.d_name; - bool is_dir = (e.d_type == file::TT_DT_DIR || e.d_type == file::TT_DT_CHR); - json << "{\"name\":\"" << escapeJson(en) << "\",\"type\":\"" << (is_dir?"dir":"file") << "\"}"; - } - } - - json << "]}"; - } - json << "]}"; - - http_server_request_set_content_type(request, "application/json"); - http_server_request_send_string(request, json.str().c_str()); - return ERROR_NONE; -} - -// Create a directory at the specified path (POST /fs/mkdir?path=/data/newdir) -error_t WebServerService::handleFsMkdir(HttpServerRequest* request, void*) { - std::string path; - if (!getQueryParam(request, "path", path) || path.empty()) { - http_server_request_send_error(request, 400, "path required"); - return ERROR_UNDEFINED; - } - std::string norm = normalizePath(path); - LOG_I(TAG, "POST /fs/mkdir requested: '%s' normalized: '%s'", path.c_str(), norm.c_str()); - if (!isAllowedBasePath(norm)) { - http_server_request_send_error(request, 403, "invalid path"); - return ERROR_UNDEFINED; - } - bool ok = file::findOrCreateDirectory(norm, 0755); - if (!ok) { http_server_request_send_error(request, 500, "mkdir failed"); return ERROR_UNDEFINED; } - http_server_request_send_string(request, "ok"); - return ERROR_NONE; -} - -static bool isRootMountPoint(const std::string& path) { - return path == "/data" || path == "/sdcard"; -} - -// Delete a file or directory (POST /fs/delete?path=/data/foo) -error_t WebServerService::handleFsDelete(HttpServerRequest* request, void*) { - std::string path; - if (!getQueryParam(request, "path", path) || path.empty()) { - http_server_request_send_error(request, 400, "path required"); - return ERROR_UNDEFINED; - } - std::string norm = normalizePath(path); - LOG_I(TAG, "POST /fs/delete requested: '%s' normalized: '%s'", path.c_str(), norm.c_str()); - if (!isAllowedBasePath(norm)) { - http_server_request_send_error(request, 403, "invalid path"); - return ERROR_UNDEFINED; - } - if (isRootMountPoint(norm)) { - http_server_request_send_error(request, 403, "cannot delete mount point"); - return ERROR_UNDEFINED; - } - bool ok = true; - if (file::isDirectory(norm)) ok = file::deleteRecursively(norm); - else if (file::isFile(norm)) ok = file::deleteFile(norm); - else ok = false; - if (!ok) { http_server_request_send_error(request, 500, "delete failed"); return ERROR_UNDEFINED; } - http_server_request_send_string(request, "ok"); - return ERROR_NONE; -} - -// Rename a file or folder (POST /fs/rename?path=/data/oldname&newName=newname) -error_t WebServerService::handleFsRename(HttpServerRequest* request, void*) { - std::string path; - std::string newName; - if (!getQueryParam(request, "path", path) || path.empty()) { - http_server_request_send_error(request, 400, "path required"); - return ERROR_UNDEFINED; - } - if (!getQueryParam(request, "newName", newName) || newName.empty()) { - http_server_request_send_error(request, 400, "newName required"); - return ERROR_UNDEFINED; - } - std::string norm = normalizePath(path); - LOG_I(TAG, "POST /fs/rename requested: '%s' normalized: '%s' -> newName: '%s'", path.c_str(), norm.c_str(), newName.c_str()); - if (!isAllowedBasePath(norm)) { - http_server_request_send_error(request, 403, "invalid path"); - return ERROR_UNDEFINED; - } - - // Basic validation of newName: must not contain path separators or '..' - // Trim whitespace from newName - auto trim = [](std::string& s){ size_t st=0; while (stst && isspace((unsigned char)s[ed-1])) --ed; s = s.substr(st, ed-st); }; - trim(newName); - if (newName.empty() || newName.find('/') != std::string::npos || newName.find('\\') != std::string::npos || newName.find("..") != std::string::npos) { - http_server_request_send_error(request, 400, "invalid newName"); - return ERROR_UNDEFINED; - } - - // compute parent directory - std::string parent = "/"; - size_t pos = norm.find_last_of('/'); - if (pos != std::string::npos) { - parent = (pos == 0) ? std::string("/") : norm.substr(0, pos); - } - - if (!isAllowedBasePath(parent)) { - http_server_request_send_error(request, 403, "invalid target parent"); - return ERROR_UNDEFINED; - } - - std::string target = file::getChildPath(parent, newName); - - // Prevent overwrite: fail if target exists - if (file::isFile(target) || file::isDirectory(target)) { - http_server_request_send_error(request, 400, "target exists"); - return ERROR_UNDEFINED; - } - - // perform rename - int r = rename(norm.c_str(), target.c_str()); - if (r != 0) { - int e = errno; - LOG_W(TAG, "rename failed errno=%d (%s) -> %s -> %s", e, strerror(e), norm.c_str(), target.c_str()); - // Return errno string to client to aid debugging - std::string msg = std::string("rename failed: ") + strerror(e); - http_server_request_send_error(request, 500, msg.c_str()); - return ERROR_UNDEFINED; - } - http_server_request_send_string(request, "ok"); - return ERROR_NONE; -} - -// endregion - -error_t WebServerService::handleReboot(HttpServerRequest* request, void*) { - - LOG_I(TAG, "POST /reboot"); - http_server_request_send_string(request, "Rebooting..."); - - // Reboot after a short delay to allow response to be sent - vTaskDelay(pdMS_TO_TICKS(2000)); -#ifdef ESP_PLATFORM - esp_restart(); -#else - LOG_W(TAG, "Reboot is not supported on this platform"); -#endif - - return ERROR_NONE; // Unreachable on ESP_PLATFORM, but satisfies function signature -} - -static error_t sendEmbeddedAsset(HttpServerRequest* request, const char* uri, const char* contentType, const uint8_t* data, size_t size) { - http_server_request_set_content_type(request, contentType); - const error_t error = http_server_request_send(request, data, size); - if (error == ERROR_NONE) { - LOG_I(TAG, "[200] %s (embedded)", uri); - } - return error; -} - -error_t WebServerService::handleAssets(HttpServerRequest* request, void*) { - // Auth check for UI access control - bool authPassed = false; - error_t authResult = validateRequestAuth(request, authPassed); - if (!authPassed) { - return authResult; - } - - char uri[256]; - http_server_request_get_uri(request, uri, sizeof(uri)); - LOG_I(TAG, "GET %s", uri); - - std::string requestedPath = normalizePath(uri); - if (requestedPath == "/.." || requestedPath.ends_with("/..") || requestedPath.find("/../") != std::string::npos) { - http_server_request_send_error(request, 400, "invalid path"); - return ERROR_UNDEFINED; - } - - // Embedded assets take priority over the SD card - if (requestedPath == "/favicon.ico") { - http_server_request_set_header(request, "Cache-Control", "public, max-age=86400"); - return sendEmbeddedAsset(request, uri, "image/png", asset_ui_spinner_32_png, asset_ui_spinner_32_png_size); - } - if (requestedPath == "/dashboard.html") { - return sendEmbeddedAsset(request, uri, "text/html", asset_app_WebServer_dashboard_html, asset_app_WebServer_dashboard_html_size); - } - if (requestedPath == "/version.json") { - return sendEmbeddedAsset(request, uri, "application/json", asset_app_WebServer_version_json, asset_app_WebServer_version_json_size); - } - - // Fallback to SD card - std::string sdPath = std::string("/sdcard/tactility/webserver") + requestedPath; - if (file::isFile(sdPath.c_str())) { - http_server_request_set_content_type(request, getContentType(sdPath)); - - FILE* fp = fopen(sdPath.c_str(), "rb"); - if (fp) { - if (http_server_request_send_chunk_start(request) != ERROR_NONE) { - fclose(fp); - return ERROR_UNDEFINED; - } - char buffer[512]; - size_t bytesRead; - while ((bytesRead = fread(buffer, 1, sizeof(buffer), fp)) > 0) { - if (http_server_request_send_chunk(request, buffer, bytesRead) != ERROR_NONE) { - fclose(fp); - return ERROR_UNDEFINED; - } - } - fclose(fp); - - http_server_request_send_chunk_end(request); // End of chunks - LOG_I(TAG, "[200] %s (from SD)", uri); - return ERROR_NONE; - } - } - - // File not found - LOG_W(TAG, "[404] %s", uri); - http_server_request_send_error(request, 404, "File not found"); - return ERROR_UNDEFINED; -} - -extern const ServiceManifest manifest = { - .id = "tactility.webserver", - .createService = create -}; - -void setWebServerEnabled(bool enabled) { - WebServerService* instance = g_webServerInstance.load(); - if (instance != nullptr) { - instance->setEnabled(enabled); - // Don't log here - startServer()/stopServer() already log the actual result - } else { - LOG_W(TAG, "WebServer service not available, cannot %s", enabled ? "start" : "stop"); - } -} - -bool isWebServerEnabled() { - WebServerService* instance = g_webServerInstance.load(); - return instance != nullptr && instance->isEnabled(); -} - -} // namespace diff --git a/Tactility/Source/settings/WebServerSettings.cpp b/Tactility/Source/settings/WebServerSettings.cpp deleted file mode 100644 index 52bcef320..000000000 --- a/Tactility/Source/settings/WebServerSettings.cpp +++ /dev/null @@ -1,264 +0,0 @@ -#include -#include -#include - -#include -#include - -#include -#include -#include - -#ifdef ESP_PLATFORM -#include -#include -#include -#else -#include -#endif - -namespace tt::settings::webserver { - -constexpr auto* TAG = "WebServerSettings"; - -static std::string getSettingsFilePath() { - char path[256]; - if (app_paths_get_user_data_path("tactility.webserversettings", "webserver.properties", path, sizeof(path)) != ERROR_NONE) { - return ""; - } - return path; -} - -// Property keys -constexpr auto* KEY_WIFI_ENABLED = "wifiEnabled"; -constexpr auto* KEY_WIFI_MODE = "wifiMode"; -constexpr auto* KEY_AP_SSID = "apSsid"; -constexpr auto* KEY_AP_PASSWORD = "apPassword"; -constexpr auto* KEY_AP_OPEN_NETWORK = "apOpenNetwork"; -constexpr auto* KEY_AP_CHANNEL = "apChannel"; -constexpr auto* KEY_WEBSERVER_ENABLED = "webServerEnabled"; -constexpr auto* KEY_WEBSERVER_PORT = "webServerPort"; -constexpr auto* KEY_WEBSERVER_AUTH_ENABLED = "webServerAuthEnabled"; -constexpr auto* KEY_WEBSERVER_USERNAME = "webServerUsername"; -constexpr auto* KEY_WEBSERVER_PASSWORD = "webServerPassword"; - -std::string generateDefaultApSsid() { -#ifdef ESP_PLATFORM - // ESP_MAC_BASE (not ESP_MAC_WIFI_STA) since chips without local WiFi silicon - // (e.g. ESP32-P4, which uses an external co-processor over ESP-Hosted) have no - // WiFi-station MAC type in eFuse; the base factory MAC exists on every chip. - uint8_t mac[6]; - if (esp_read_mac(mac, ESP_MAC_BASE) != ESP_OK) { - return "Tactility-0000"; - } - char ssid[16]; - snprintf(ssid, sizeof(ssid), "Tactility-%02X%02X", mac[2] ^ mac[3], mac[4] ^ mac[5]); - return std::string(ssid); -#else - return "Tactility-0000"; -#endif -} - -/** - * @brief Generate a cryptographically secure random string for credentials - * @param length The desired length of the string - * @return A random alphanumeric string - */ -std::string generateRandomCredential(size_t length) { - static constexpr char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - static constexpr size_t charsetSize = sizeof(charset) - 1; - - std::string result; - result.reserve(length); - -#ifdef ESP_PLATFORM - for (size_t i = 0; i < length; ++i) { - uint32_t randomValue = esp_random(); - result += charset[randomValue % charsetSize]; - } -#else - static std::random_device rd; - static std::mt19937 gen(rd()); - std::uniform_int_distribution<> dis(0, charsetSize - 1); - for (size_t i = 0; i < length; ++i) { - result += charset[dis(gen)]; - } -#endif - - return result; -} - -/** - * @brief Check if a credential value is empty (needs auto-generation) - * @param value The credential value to check - * @return true if the credential is empty and needs generation - */ -static bool isEmptyCredential(const std::string& value) { - return value.empty(); -} - -bool load(WebServerSettings& settings) { - auto settings_path = getSettingsFilePath(); - if (!file::isFile(settings_path)) { - return false; - } - - std::map map; - if (!file::loadPropertiesFile(settings_path, map)) { - return false; - } - - // Parse all settings from the map - auto wifi_enabled = map.find(KEY_WIFI_ENABLED); - auto wifi_mode = map.find(KEY_WIFI_MODE); - auto ap_ssid = map.find(KEY_AP_SSID); - auto ap_password = map.find(KEY_AP_PASSWORD); - auto ap_open_network = map.find(KEY_AP_OPEN_NETWORK); - auto ap_channel = map.find(KEY_AP_CHANNEL); - auto webserver_enabled = map.find(KEY_WEBSERVER_ENABLED); - auto webserver_port = map.find(KEY_WEBSERVER_PORT); - auto webserver_auth_enabled = map.find(KEY_WEBSERVER_AUTH_ENABLED); - auto webserver_username = map.find(KEY_WEBSERVER_USERNAME); - auto webserver_password = map.find(KEY_WEBSERVER_PASSWORD); - - // WiFi settings - settings.wifiEnabled = (wifi_enabled != map.end()) - ? (wifi_enabled->second == "1" || wifi_enabled->second == "true") - : false; // Default disabled - - settings.wifiMode = (wifi_mode != map.end() && wifi_mode->second == "1") - ? WiFiMode::AccessPoint - : WiFiMode::Station; - - auto parseInt = [](const std::string& value, int min, int max, int fallback) -> int { - int v = 0; - auto [ptr, ec] = std::from_chars(value.data(), value.data() + value.size(), v); - if (ec != std::errc{} || v < min || v > max) { - return fallback; - } - return v; - }; - - // AP mode settings - settings.apSsid = (ap_ssid != map.end() && !ap_ssid->second.empty()) - ? ap_ssid->second - : generateDefaultApSsid(); - settings.apPassword = (ap_password != map.end()) ? ap_password->second : ""; - settings.apOpenNetwork = (ap_open_network != map.end()) - ? (ap_open_network->second == "1" || ap_open_network->second == "true") - : false; - settings.apChannel = (ap_channel != map.end()) - ? static_cast(parseInt(ap_channel->second, 1, 13, 1)) - : 1; - - // Security: If AP password is empty, generate a strong random password in memory. - // Skip this if user explicitly wants an open network. - // Note: We only auto-generate for EMPTY passwords, not user-set ones. - // This is a read-only function: the generated password is NOT persisted here — - // callers that want it saved must call save() explicitly. - if (!settings.apOpenNetwork && isEmptyCredential(settings.apPassword)) { - LOG_I(TAG, "AP password is empty - generating secure random password (not persisted)"); - - // Generate 12-character random password (alphanumeric, ~71 bits of entropy) - // WPA2 requires 8-63 characters, so 12 is well within range - settings.apPassword = generateRandomCredential(12); - } - - // Web server settings - settings.webServerEnabled = (webserver_enabled != map.end()) - ? (webserver_enabled->second == "1" || webserver_enabled->second == "true") - : false; - - settings.webServerPort = (webserver_port != map.end()) - ? static_cast(parseInt(webserver_port->second, 1, 65535, 80)) - : 80; - - // Web server auth settings - settings.webServerAuthEnabled = (webserver_auth_enabled != map.end()) - ? (webserver_auth_enabled->second == "1" || webserver_auth_enabled->second == "true") - : false; - - // Load credentials from file, defaulting to empty if not present - settings.webServerUsername = (webserver_username != map.end()) ? webserver_username->second : ""; - settings.webServerPassword = (webserver_password != map.end()) ? webserver_password->second : ""; - - // Security: If auth is enabled but credentials are empty, generate strong random - // credentials in memory. Note: We only auto-generate for EMPTY credentials, allowing - // users to set their own. - // This is a read-only function: the generated credentials are NOT persisted here — - // callers that want them saved (so they're consistent across reboots) must call - // save() explicitly. - if (settings.webServerAuthEnabled && - (isEmptyCredential(settings.webServerUsername) || isEmptyCredential(settings.webServerPassword))) { - - LOG_I(TAG, "Auth enabled with empty credentials - generating secure random credentials (not persisted)"); - - // Generate 12-character random credentials (alphanumeric, ~71 bits of entropy each) - settings.webServerUsername = generateRandomCredential(12); - settings.webServerPassword = generateRandomCredential(12); - } - - return true; -} - -WebServerSettings getDefault() { - return WebServerSettings{ - .wifiEnabled = false, // Default WiFi OFF - .wifiMode = WiFiMode::Station, - .apSsid = generateDefaultApSsid(), - .apPassword = "", // Empty - will be auto-generated on first use (unless apOpenNetwork) - .apOpenNetwork = false, // Default to secured network - .apChannel = 1, - .webServerEnabled = false, // Default WebServer OFF for security - .webServerPort = 80, - .webServerAuthEnabled = false, // Auth disabled by default - .webServerUsername = "", // Empty - will be generated if auth is enabled - .webServerPassword = "" // Empty - will be generated if auth is enabled - }; -} - -WebServerSettings loadOrGetDefault() { - WebServerSettings settings; - - bool loadedFromFlash = load(settings); - if (!loadedFromFlash) { - // No properties file yet (e.g. first boot) - use defaults in memory (WiFi OFF, - // WebServer OFF). Read-only function: does NOT persist these defaults — callers - // that want them saved must call save() explicitly. - settings = getDefault(); - } - - return settings; -} - -bool save(const WebServerSettings& settings) { - std::map map; - - // WiFi settings - map[KEY_WIFI_ENABLED] = settings.wifiEnabled ? "1" : "0"; - map[KEY_WIFI_MODE] = (settings.wifiMode == WiFiMode::AccessPoint) ? "1" : "0"; - - // AP mode settings - map[KEY_AP_SSID] = settings.apSsid; - map[KEY_AP_PASSWORD] = settings.apPassword; - map[KEY_AP_OPEN_NETWORK] = settings.apOpenNetwork ? "1" : "0"; - map[KEY_AP_CHANNEL] = std::to_string(settings.apChannel); - - // Web server settings - map[KEY_WEBSERVER_ENABLED] = settings.webServerEnabled ? "1" : "0"; - map[KEY_WEBSERVER_PORT] = std::to_string(settings.webServerPort); - - // Web server auth settings - map[KEY_WEBSERVER_AUTH_ENABLED] = settings.webServerAuthEnabled ? "1" : "0"; - map[KEY_WEBSERVER_USERNAME] = settings.webServerUsername; - map[KEY_WEBSERVER_PASSWORD] = settings.webServerPassword; - - auto settings_path = getSettingsFilePath(); - if (!file::findOrCreateParentDirectory(settings_path, 0755)) { - LOG_E(TAG, "Failed to create parent dir for %s", settings_path.c_str()); - return false; - } - return file::savePropertiesFile(settings_path, map); -} - -} From d936e867dc7afb1b7ca7e131b3e7324dad0001d3 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Tue, 20 Oct 2026 21:44:38 +0200 Subject: [PATCH 3/9] Remove launcher override --- Devices/waveshare-s3-touch-amoled-206/device.properties | 2 -- 1 file changed, 2 deletions(-) diff --git a/Devices/waveshare-s3-touch-amoled-206/device.properties b/Devices/waveshare-s3-touch-amoled-206/device.properties index a5861b697..05a9ebab7 100644 --- a/Devices/waveshare-s3-touch-amoled-206/device.properties +++ b/Devices/waveshare-s3-touch-amoled-206/device.properties @@ -2,8 +2,6 @@ general.vendor=Waveshare general.name=S3 Touch AMOLED 2.06" general.incubating=true -apps.launcherAppId=tactility.launcher - hardware.target=ESP32S3 hardware.flashSize=32MB hardware.spiRam=true From feca0bd8d74c6d7d3d281cc7f350a12134d7a89d Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Tue, 20 Oct 2026 22:06:34 +0200 Subject: [PATCH 4/9] Fixes --- .../mock-drivers-module/source/mock_wifi.cpp | 3 ++- .../source/drivers/esp32_wifi.cpp | 24 ++++++++++++------- .../include/tactility/drivers/wifi.h | 6 ++--- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Drivers/mock-drivers-module/source/mock_wifi.cpp b/Drivers/mock-drivers-module/source/mock_wifi.cpp index a55cb62e7..6d3214ba9 100644 --- a/Drivers/mock-drivers-module/source/mock_wifi.cpp +++ b/Drivers/mock-drivers-module/source/mock_wifi.cpp @@ -38,6 +38,7 @@ constexpr size_t MOCK_SCAN_RESULT_COUNT = sizeof(MOCK_SCAN_RESULTS) / sizeof(MOC constexpr int8_t MOCK_CONNECTED_RSSI = -30; constexpr const char* MOCK_IPV4_ADDRESS = "192.168.1.2"; constexpr const char* MOCK_ACCESS_POINT_IPV4_ADDRESS = "192.168.4.1"; +constexpr uint8_t MOCK_ACCESS_POINT_MAX_CONNECTIONS = 15; struct MockWifiCtx { Device* device = nullptr; @@ -119,7 +120,7 @@ error_t apiAccessPointStart(Device* device, const WifiAccessPointConfig* config) const size_t passwordLength = strnlen(config->password, sizeof(config->password)); const bool ssidValid = ssidLength > 0 && ssidLength < sizeof(config->ssid); const bool passwordValid = passwordLength == 0 || (passwordLength >= 8 && passwordLength < sizeof(config->password)); - if (!ssidValid || !passwordValid || config->channel > 13) { + if (!ssidValid || !passwordValid || config->channel > 13 || config->max_connections > MOCK_ACCESS_POINT_MAX_CONNECTIONS) { return ERROR_INVALID_ARGUMENT; } diff --git a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp index abf78d209..6b1a55f92 100644 --- a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp +++ b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp @@ -514,8 +514,17 @@ constexpr uint8_t ACCESS_POINT_DEFAULT_CHANNEL = 1; constexpr uint8_t ACCESS_POINT_MAX_CHANNEL = 13; constexpr uint8_t ACCESS_POINT_DEFAULT_MAX_CONNECTIONS = 4; -// Detaches the netif from the internal WiFi event handlers before the radio is released, -// otherwise esp_netif_destroy() can race with esp_wifi_stop()'s own netif teardown. +// Detaches the netif from the internal WiFi event handlers before it is destroyed, +// otherwise the Wi-Fi driver keeps a pointer to the freed netif. +void destroy_access_point_netif(Esp32WifiCtx* ctx) { + if (ctx->accessPointNetif != nullptr) { + esp_wifi_clear_default_wifi_driver_and_handlers(ctx->accessPointNetif); + esp_netif_destroy(ctx->accessPointNetif); + ctx->accessPointNetif = nullptr; + } +} + +// The netif is detached before the radio is released, otherwise esp_netif_destroy() can race with esp_wifi_stop()'s own netif teardown. void release_access_point(Esp32WifiCtx* ctx) { if (ctx->accessPointNetif != nullptr) { esp_wifi_clear_default_wifi_driver_and_handlers(ctx->accessPointNetif); @@ -543,7 +552,7 @@ error_t api_access_point_start(Device* device, const WifiAccessPointConfig* conf const size_t password_length = strnlen(config->password, sizeof(config->password)); const bool ssid_valid = ssid_length > 0 && ssid_length < sizeof(config->ssid); const bool password_valid = password_length == 0 || (password_length >= 8 && password_length < sizeof(config->password)); - if (!ssid_valid || !password_valid || config->channel > ACCESS_POINT_MAX_CHANNEL) { + if (!ssid_valid || !password_valid || config->channel > ACCESS_POINT_MAX_CHANNEL || config->max_connections > ESP_WIFI_MAX_CONN_NUM) { return ERROR_INVALID_ARGUMENT; } @@ -572,21 +581,20 @@ error_t api_access_point_start(Device* device, const WifiAccessPointConfig* conf set_ip_result != ESP_OK || (start_dhcp_result != ESP_OK && start_dhcp_result != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED)) { LOG_E(TAG, "Failed to set up the access point network"); - esp_netif_destroy(ctx->accessPointNetif); - ctx->accessPointNetif = nullptr; + destroy_access_point_netif(ctx); return ERROR_RESOURCE; } // Acquires the shared radio, which merges into WIFI_MODE_APSTA when the station owns the radio already if (!esp32_wifi_radio_acquire(WIFI_MODE_AP)) { LOG_E(TAG, "Failed to acquire the radio for the access point"); - esp_netif_destroy(ctx->accessPointNetif); - ctx->accessPointNetif = nullptr; + destroy_access_point_netif(ctx); return ERROR_RESOURCE; } wifi_config_t wifi_config {}; - strncpy(reinterpret_cast(wifi_config.ap.ssid), config->ssid, sizeof(wifi_config.ap.ssid) - 1); + // The SSID field has no terminator for a name of 32 bytes: ssid_len gives the length + memcpy(wifi_config.ap.ssid, config->ssid, ssid_length); wifi_config.ap.ssid_len = static_cast(ssid_length); if (password_length > 0) { wifi_config.ap.authmode = WIFI_AUTH_WPA2_PSK; diff --git a/TactilityKernel/include/tactility/drivers/wifi.h b/TactilityKernel/include/tactility/drivers/wifi.h index 58af1b921..3f4866365 100644 --- a/TactilityKernel/include/tactility/drivers/wifi.h +++ b/TactilityKernel/include/tactility/drivers/wifi.h @@ -77,10 +77,10 @@ struct WifiAccessPointConfig { /** Required. 1 to 32 bytes, null-terminated. */ char ssid[33]; /** An empty password makes an open network. Otherwise it needs 8 to 63 characters (WPA2-PSK). */ - char password[65]; + char password[64]; /** The Wi-Fi channel from 1 to 13. 0 means the default (1). */ uint8_t channel; - /** The number of devices that can connect. 0 means the default (4). */ + /** The number of devices that can connect. 0 means the default (4). The limit depends on the platform: 15 on the ESP32 family, 4 on the ESP32-C2. */ uint8_t max_connections; }; @@ -294,7 +294,7 @@ struct WifiApi { * @param[in] device the wifi device * @param[in] config the access point settings * @retval ERROR_NONE on success - * @retval ERROR_INVALID_ARGUMENT the SSID is empty or too long, the password is not empty and not 8 to 63 characters, or the channel is above 13 + * @retval ERROR_INVALID_ARGUMENT the SSID is empty or too long, the password is not empty and not 8 to 63 characters, the channel is above 13, or max_connections is above the limit of the platform * @retval ERROR_INVALID_STATE the access point is started already. Stop it first * @retval ERROR_RESOURCE the access point could not be started */ From d3551bd2d94f1eb5666fd9be4448dd60e7e13ab6 Mon Sep 17 00:00:00 2001 From: Loelin Date: Sun, 11 Oct 2026 13:43:36 -0400 Subject: [PATCH 5/9] Waveshare S3 Touch AMOLED 1.8": add device support Adds support for the Waveshare ESP32-S3-Touch-AMOLED-1.8 (V1: SH8601 panel + FT3168 touch), a 368x448 AMOLED board with 16MB flash / 8MB PSRAM. Reuses the sh8601, ft5x06, axp2101, qmi8658 and es8311 drivers. Board specifics: - the panel/touch reset lines hang off a TCA9554 I2C expander, so the module start pulses EXIO0/1/2/6 (low then high) before the display/touch devices start; - the TF card is on SDMMC (slot 1, 1-bit: CLK=2, CMD=1, D0=3); - the same board-local LVGL insets/scaling and Bluetooth reconnect hooks as the 2.06 port. --- .../CMakeLists.txt | 19 ++ .../device.properties | 30 +++ .../waveshare-s3-touch-amoled-18/module.yaml | 10 + .../source/bluetooth_reconnect.cpp | 225 ++++++++++++++++++ .../source/bluetooth_reconnect.h | 14 ++ .../source/expander_reset.cpp | 86 +++++++ .../source/lvgl_insets.cpp | 50 ++++ .../source/lvgl_scaling.cpp | 86 +++++++ .../source/module.cpp | 32 +++ .../waveshare,s3-touch-amoled-18.dts | 147 ++++++++++++ 10 files changed, 699 insertions(+) create mode 100644 Devices/waveshare-s3-touch-amoled-18/CMakeLists.txt create mode 100644 Devices/waveshare-s3-touch-amoled-18/device.properties create mode 100644 Devices/waveshare-s3-touch-amoled-18/module.yaml create mode 100644 Devices/waveshare-s3-touch-amoled-18/source/bluetooth_reconnect.cpp create mode 100644 Devices/waveshare-s3-touch-amoled-18/source/bluetooth_reconnect.h create mode 100644 Devices/waveshare-s3-touch-amoled-18/source/expander_reset.cpp create mode 100644 Devices/waveshare-s3-touch-amoled-18/source/lvgl_insets.cpp create mode 100644 Devices/waveshare-s3-touch-amoled-18/source/lvgl_scaling.cpp create mode 100644 Devices/waveshare-s3-touch-amoled-18/source/module.cpp create mode 100644 Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts diff --git a/Devices/waveshare-s3-touch-amoled-18/CMakeLists.txt b/Devices/waveshare-s3-touch-amoled-18/CMakeLists.txt new file mode 100644 index 000000000..3321e6ff6 --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/CMakeLists.txt @@ -0,0 +1,19 @@ +file(GLOB_RECURSE SOURCE_FILES source/*.c*) + +idf_component_register( + SRCS ${SOURCE_FILES} + INCLUDE_DIRS "source" + REQUIRES TactilityKernel Tactility TactilityFreeRtos lvgl-module lvgl-window-manager-module esp_driver_i2c esp_driver_gpio +) + +# Board-local link-time hooks (see source/lvgl_scaling.cpp and source/lvgl_insets.cpp): keep the +# Material theme's widget sizes usable while tightening its spacing, and keep the UI clear of the +# top/bottom edges. No core Tactility/LVGL source is modified. +idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=window_manager_configure" APPEND) +idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_display_create" APPEND) +idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_top" APPEND) +idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_bottom" APPEND) +idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_left" APPEND) +idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_right" APPEND) +idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_row" APPEND) +idf_build_set_property(LINK_OPTIONS "-Wl,--wrap=lv_style_set_pad_column" APPEND) diff --git a/Devices/waveshare-s3-touch-amoled-18/device.properties b/Devices/waveshare-s3-touch-amoled-18/device.properties new file mode 100644 index 000000000..db3153c7d --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/device.properties @@ -0,0 +1,30 @@ +general.vendor=Waveshare +general.name=S3 Touch AMOLED 1.8" +general.incubating=true + +apps.launcherAppId=tactility.launcher + +hardware.target=ESP32S3 +hardware.flashSize=16MB +hardware.spiRam=true +hardware.spiRamMode=OCT +hardware.spiRamSpeed=120M +hardware.tinyUsbMsc=true +hardware.esptoolFlashFreq=120M +hardware.bluetooth=true + +storage.userDataLocation=Internal + +display.size=1.8" +display.shape=rectangle +display.dpi=322 + +lvgl.colorDepth=16 +lvgl.uiDensity=default +lvgl.fontSize=22 + +sdkconfig.CONFIG_TASK_WDT_TIMEOUT_S=10 +sdkconfig.CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL=131072 +# LVGL v9 symbol (v8 was LV_DISP_DEF_REFR_PERIOD). Upstream moved this out of device.py, +# so pin it per-device: 10 ms keeps the panel above the 16 ms animation tick. +sdkconfig.CONFIG_LV_DEF_REFR_PERIOD=10 diff --git a/Devices/waveshare-s3-touch-amoled-18/module.yaml b/Devices/waveshare-s3-touch-amoled-18/module.yaml new file mode 100644 index 000000000..6fcf91f9e --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/module.yaml @@ -0,0 +1,10 @@ +dependencies: + - Platforms/platform-esp32 + - Drivers/sh8601-module + - Drivers/axp2101-module + - Drivers/qmi8658-module + - Drivers/ft5x06-module + - Drivers/es8311-module + - Drivers/audio-stream-module + - Drivers/tca9534-module +dts: waveshare,s3-touch-amoled-18.dts diff --git a/Devices/waveshare-s3-touch-amoled-18/source/bluetooth_reconnect.cpp b/Devices/waveshare-s3-touch-amoled-18/source/bluetooth_reconnect.cpp new file mode 100644 index 000000000..a6f592bbc --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/source/bluetooth_reconnect.cpp @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Board-local Bluetooth auto-reconnect for the Waveshare S3 Touch AMOLED 1.8. +// +// Tactility's own auto-connect runs on the "bt-events" thread +// (Tactility/Source/bluetooth/Bluetooth.cpp). That thread can be blocked while the radio is on, +// so saved peers (e.g. a BLE keyboard) never get reconnected. This task subscribes to the kernel +// BT driver directly - its own subscription and its own thread, so it doesn't depend on bt-events +// - and, whenever the radio is on, scans for and reconnects every saved peer it sees. + +#include + +#if defined(CONFIG_BT_NIMBLE_ENABLED) + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace tt::bluetooth { + +// The core HID host resolves a peer's address type from a scan cache that is filled in by the +// (blocked) bt-events thread. The helper that fills it is internal to Tactility (declared in +// Private/Tactility/bluetooth/BluetoothPrivate.h, which isn't exported to components), so it is +// re-declared here to prime that cache with the address type this task learns from its own scan. +void cacheScanAddr(const uint8_t addr[6], uint8_t addr_type); + +} // namespace tt::bluetooth + +namespace { + +constexpr auto* TAG = "WsBtReconnect"; + +// How long the task blocks per wait, and how many of those slices make up one periodic retry. +// The slice bounds how quickly stop() is noticed; the total paces the retry scan. +constexpr TickType_t WAIT_SLICE = pdMS_TO_TICKS(500); +constexpr int RETRY_SLICES = 40; // ~20 s +// This task runs the same heavy work the core dispatches to the main task (settings::loadAll() +// plus tt::bluetooth::connect() -> hidHostConnect()), so it needs at least the main task's stack +// (CONFIG_ESP_MAIN_TASK_STACK_SIZE, 6144). Sized to match NimBLE's host task. +constexpr size_t TASK_STACK_SIZE = 8192; + +// The advertised name must fit the 31-byte legacy advertising payload (<= 26 chars alongside the +// advertising flags). "S3 Touch AMOLED 1.8" fits, but it is pinned here so a change to the (longer) +// UI device name can't silently break advertising. +constexpr const char* ADVERTISED_NAME = "S3 Touch AMOLED 1.8"; + +std::atomic reconnect_running { false }; +std::unique_ptr reconnect_thread; + +struct SavedPeer { + std::array addr; + int profile_id; + bool attempted; +}; + +// Only ever touched by the task. +std::vector saved_peers; +Device* bt_device = nullptr; + +void refresh_saved_peers() { + saved_peers.clear(); + for (const auto& peer : tt::bluetooth::settings::loadAll()) { + saved_peers.push_back(SavedPeer { .addr = peer.addr, .profile_id = peer.profileId, .attempted = false }); + } +} + +SavedPeer* find_saved_peer(const BtAddr addr) { + for (auto& peer : saved_peers) { + if (std::memcmp(peer.addr.data(), addr, 6) == 0) { + return &peer; + } + } + return nullptr; +} + +void connect_peer(const BtPeerRecord& record) { + SavedPeer* peer = find_saved_peer(record.addr); + if (peer == nullptr || peer->attempted) { + return; + } + peer->attempted = true; + + // Prime the core HID host cache with the address type from our own scan. + tt::bluetooth::cacheScanAddr(record.addr, record.addr_type); + + const auto hex = tt::bluetooth::settings::addrToHex(peer->addr); + LOG_I(TAG, "Reconnecting saved peer %s (profile=%d)", hex.c_str(), peer->profile_id); + tt::bluetooth::connect(peer->addr, peer->profile_id); +} + +void scan_if_idle() { + if (bt_device == nullptr || saved_peers.empty()) { + return; + } + if (!tt::bluetooth::isRadioOnOrPending(bt_device)) { + return; + } + if (bluetooth_is_scanning(bt_device)) { + return; + } + for (auto& peer : saved_peers) { + peer.attempted = false; + } + bluetooth_scan_start(bt_device); +} + +int32_t task_main() { + TaskEventGroup event_group {}; + task_event_group_construct(&event_group); + + // Wait for the Bluetooth device to be constructed and started (the module starts before the + // devicetree's devices do). + while (reconnect_running.load()) { + if (device_get_first_active_by_type(&BLUETOOTH_TYPE, &bt_device) == ERROR_NONE) { + break; + } + bt_device = nullptr; + vTaskDelay(pdMS_TO_TICKS(500)); + } + + BtEventSubscription subscription {}; + if (!reconnect_running.load() || bt_device == nullptr || + bluetooth_event_subscribe(bt_device, &subscription, &event_group) != ERROR_NONE) { + if (bt_device != nullptr) { + device_put(bt_device); + bt_device = nullptr; + } + task_event_group_destruct(&event_group); + return 0; + } + + // Advertise a name that fits the legacy advertising payload (see ADVERTISED_NAME). The driver + // restarts advertising with it if the radio is already on. + if (bluetooth_set_device_name(bt_device, ADVERTISED_NAME) != ERROR_NONE) { + LOG_W(TAG, "Failed to set the advertised Bluetooth name"); + } + + refresh_saved_peers(); + scan_if_idle(); + + int slices = 0; + while (reconnect_running.load()) { + task_event_group_wait_any(&event_group, nullptr, WAIT_SLICE); + + BtEvent event {}; + while (bluetooth_event_poll(&subscription, &event) == ERROR_NONE) { + switch (event.type) { + case BT_EVENT_RADIO_STATE_CHANGED: + if (event.radio_state == BT_RADIO_STATE_ON) { + refresh_saved_peers(); + scan_if_idle(); + } + break; + case BT_EVENT_SCAN_FINISHED: + LOG_I(TAG, "Scan finished"); + break; + case BT_EVENT_PEER_FOUND: + connect_peer(event.peer); + break; + default: + break; + } + } + + // Periodic retry: pick up newly paired peers and rescan when nothing connected yet. + if (++slices >= RETRY_SLICES) { + slices = 0; + refresh_saved_peers(); + scan_if_idle(); + } + } + + bluetooth_event_unsubscribe(bt_device, &subscription); + device_put(bt_device); + bt_device = nullptr; + task_event_group_destruct(&event_group); + return 0; +} + +void task_start() { + reconnect_thread = std::make_unique( + "ws-bt-reconnect", + TASK_STACK_SIZE, + []() -> int32_t { return task_main(); } + ); + reconnect_thread->start(); +} + +} // namespace + +void waveshare_bluetooth_reconnect_start() { + if (reconnect_running.exchange(true)) { + return; + } + task_start(); +} + +void waveshare_bluetooth_reconnect_stop() { + if (!reconnect_running.exchange(false)) { + return; + } + if (reconnect_thread != nullptr) { + reconnect_thread->join(); + reconnect_thread.reset(); + } +} + +#else + +void waveshare_bluetooth_reconnect_start() {} +void waveshare_bluetooth_reconnect_stop() {} + +#endif // CONFIG_BT_NIMBLE_ENABLED diff --git a/Devices/waveshare-s3-touch-amoled-18/source/bluetooth_reconnect.h b/Devices/waveshare-s3-touch-amoled-18/source/bluetooth_reconnect.h new file mode 100644 index 000000000..bcd5e3a6b --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/source/bluetooth_reconnect.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +/** + * Board-local Bluetooth auto-reconnect for the Waveshare S3 Touch AMOLED 1.8. + * + * Starts a task that scans and reconnects saved (paired) BLE peers. It runs its own kernel BT + * event subscription on its own thread instead of relying on Tactility's "bt-events" thread, + * which can block while the radio is on. + */ +void waveshare_bluetooth_reconnect_start(); + +/** Stops the task, if running. */ +void waveshare_bluetooth_reconnect_stop(); diff --git a/Devices/waveshare-s3-touch-amoled-18/source/expander_reset.cpp b/Devices/waveshare-s3-touch-amoled-18/source/expander_reset.cpp new file mode 100644 index 000000000..c73137e92 --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/source/expander_reset.cpp @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +// The Waveshare 1.8" board does not wire the AMOLED panel or touch reset lines to ESP32 GPIOs. +// They hang off a TCA9554 I2C expander (address 0x20), whose pins the board's own examples drive +// low then high (EXIO0, EXIO1, EXIO2 and EXIO6) before initializing the panel and touch +// controller. Without that pulse the SH8601 panel stays blank and the FT3168 touch NAKs every +// I2C transaction (the later "FT5x06 init failed"). +// +// This runs from the board module's start(), which is before the kernel constructs and starts the +// devicetree's devices, so the reset is released ahead of the display/touch drivers' own init. It +// uses a temporary ESP-IDF I2C master (on a spare port, so it can't collide with the kernel's +// i2c0) and tears it down again, leaving the bus for i2c0 to set up afterwards. + +#include + +#include +#include +#include +#include + +namespace { + +constexpr auto* TAG = "ws-amoled18-expander"; + +constexpr uint8_t EXPANDER_ADDRESS = 0x20; + +constexpr gpio_num_t I2C_SDA = GPIO_NUM_15; +constexpr gpio_num_t I2C_SCL = GPIO_NUM_14; + +// TCA9554 register map (shared with the TCA9534). +constexpr uint8_t REG_OUTPUT = 0x01; +constexpr uint8_t REG_CONFIG = 0x03; + +// EXIO0/1/2/6 drive the panel and touch reset lines. +constexpr uint8_t RESET_PINS = (uint8_t)((1u << 0) | (1u << 1) | (1u << 2) | (1u << 6)); + +bool write_register(i2c_master_dev_handle_t device, uint8_t reg, uint8_t value) { + uint8_t buffer[2] = { reg, value }; + return i2c_master_transmit(device, buffer, sizeof(buffer), -1) == ESP_OK; +} + +} // namespace + +void waveshare_amoled18_expander_reset() { + i2c_master_bus_config_t bus_config = {}; + bus_config.clk_source = I2C_CLK_SRC_DEFAULT; + bus_config.i2c_port = I2C_NUM_1; // spare port; i2c0 belongs to the kernel devicetree + bus_config.sda_io_num = I2C_SDA; + bus_config.scl_io_num = I2C_SCL; + bus_config.glitch_ignore_cnt = 7; + bus_config.flags.enable_internal_pullup = true; + + i2c_master_bus_handle_t bus = nullptr; + esp_err_t err = i2c_new_master_bus(&bus_config, &bus); + if (err != ESP_OK) { + LOG_W(TAG, "Failed to open I2C bus: %s", esp_err_to_name(err)); + return; + } + + i2c_device_config_t device_config = {}; + device_config.dev_addr_length = I2C_ADDR_BIT_LEN_7; + device_config.device_address = EXPANDER_ADDRESS; + device_config.scl_speed_hz = 400000; + + i2c_master_dev_handle_t device = nullptr; + err = i2c_master_bus_add_device(bus, &device_config, &device); + if (err == ESP_OK) { + // EXIO0/1/2/6 as outputs (config bit 0 = output), the rest left as inputs; then the + // reset pulse itself. + if (!write_register(device, REG_CONFIG, (uint8_t)~RESET_PINS) || + !write_register(device, REG_OUTPUT, 0x00)) { + LOG_W(TAG, "Expander reset: initial writes failed"); + } + vTaskDelay(pdMS_TO_TICKS(20)); + if (!write_register(device, REG_OUTPUT, RESET_PINS)) { + LOG_W(TAG, "Expander reset: release write failed"); + } else { + LOG_I(TAG, "Panel/touch reset released via TCA9554"); + } + i2c_master_bus_rm_device(device); + } else { + LOG_W(TAG, "Failed to add expander device: %s", esp_err_to_name(err)); + } + + i2c_del_master_bus(bus); +} diff --git a/Devices/waveshare-s3-touch-amoled-18/source/lvgl_insets.cpp b/Devices/waveshare-s3-touch-amoled-18/source/lvgl_insets.cpp new file mode 100644 index 000000000..3050f0975 --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/source/lvgl_insets.cpp @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Keeps this board's UI clear of the display edges with a fixed inset. +// +// Core Tactility only applies the insets it derives from the display shape for round displays (see +// Tactility/Source/lvgl/Lvgl.cpp), and this rectangular AMOLED reports no shape, so instead of +// changing core source this wraps the window-manager screen-init callback - the same board-local +// link-time wrapping used in lvgl_scaling.cpp. +// +// The padding is applied to the window-manager root widget (the argument the screen-init callback +// receives and builds its chrome under), not just to the app container it returns. That way the +// statusbar, which the callback adds as a sibling of the app container, is inset with the rest of +// the UI instead of sitting on the top edge of the display. +// +// lv_obj_set_style_pad_all() writes the local style prop directly, so it is NOT affected by the +// /8 padding wrapper in lvgl_scaling.cpp (that one only covers lv_style_set_pad_*). + +#include + +#include + +#include + +namespace { + +// Fixed insets, in pixels: horizontal on the left/right, vertical on the top/bottom. +constexpr int32_t INSET_HORIZONTAL_PX = 50; +constexpr int32_t INSET_VERTICAL_PX = 30; + +// The callback passed to window_manager_configure(), invoked later to build the window tree. +WindowManagerScreenInitFn original_screen_init = nullptr; + +} // namespace + +extern "C" { + +static lv_obj_t* wrapping_screen_init(lv_obj_t* root_widget) { + lv_obj_set_style_pad_hor(root_widget, INSET_HORIZONTAL_PX, LV_STATE_DEFAULT); + lv_obj_set_style_pad_ver(root_widget, INSET_VERTICAL_PX, LV_STATE_DEFAULT); + return original_screen_init != nullptr ? original_screen_init(root_widget) : root_widget; +} + +void __real_window_manager_configure(WindowManagerScreenInitFn screen_init); + +void __wrap_window_manager_configure(WindowManagerScreenInitFn screen_init) { + original_screen_init = screen_init; + __real_window_manager_configure(screen_init != nullptr ? wrapping_screen_init : nullptr); +} + +} // extern "C" diff --git a/Devices/waveshare-s3-touch-amoled-18/source/lvgl_scaling.cpp b/Devices/waveshare-s3-touch-amoled-18/source/lvgl_scaling.cpp new file mode 100644 index 000000000..204b3ac2f --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/source/lvgl_scaling.cpp @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Tactility's Material themes (Modules/lvgl-module) derive both widget metrics (switch/checkbox/ +// slider sizes, radius, borders, ...) and spacing (padding/gaps) from the display DPI. On this +// high-DPI panel (`display.dpi` = 322) those metrics are far too large relative to the text, but +// simply lowering the DPI to tighten the spacing also shrinks the widgets to unusable sizes - the +// switch track, for example, is LV_DPX_CALC(dpi, 52) x LV_DPX_CALC(dpi, 32). +// +// So the two are decoupled here, board-locally, with link-time wrappers (the same mechanism used +// elsewhere) instead of editing core Tactility/LVGL source: +// - lv_display_create(): the display DPI tracks the text font, so widget sizes stay usable; +// - lv_style_set_pad_*(): the theme's style paddings/gaps are divided, so spacing stays tight. +// Only the two theme files use style-based padding (apps use lv_obj_set_style_*, which does not go +// through these setters), so the padding wrappers affect the theme and nothing else. + +#include + +#include +#include + +constexpr auto* TAG = "waveshare-s3-touch-amoled-18"; + +namespace { + +// Widget metrics scale with the display DPI; this puts them near Material's baseline at the +// default 22 px font (e.g. the switch track is ~50 x 31 px). +constexpr int32_t WIDGET_DPI_PER_FONT_PIXEL = 7; + +// Spacing is divided by this instead of following the widget DPI (e.g. the theme's largest control +// padding, dp(24), becomes ~3 px). +constexpr int32_t PADDING_DIVISOR = 8; + +int32_t scalePadding(int32_t value) { + // Keep zero and negative values (handle/knob geometry) as the theme designed them. + if (value <= 0) { + return value; + } + return (value + PADDING_DIVISOR / 2) / PADDING_DIVISOR; +} + +int32_t displayDpiForFontSize(int32_t font_size) { + return font_size * WIDGET_DPI_PER_FONT_PIXEL; +} + +} // namespace + +extern "C" { + +lv_display_t* __real_lv_display_create(int32_t hor_res, int32_t ver_res); + +lv_display_t* __wrap_lv_display_create(int32_t hor_res, int32_t ver_res) { + lv_display_t* display = __real_lv_display_create(hor_res, ver_res); + if (display == nullptr) { + return display; + } + + const int32_t font_size = static_cast(lvgl_get_text_font_height(FONT_SIZE_DEFAULT)); + if (font_size <= 0) { + return display; + } + + const int32_t dpi = displayDpiForFontSize(font_size); + if (lv_display_get_dpi(display) != dpi) { + lv_display_set_dpi(display, dpi); + LOG_I(TAG, "LVGL display DPI set to %d for text font %d px", (int)dpi, (int)font_size); + } + + return display; +} + +// lv_style_set_pad_all/hor/ver/gap are static inline and call these base setters, so wrapping the +// six base ones covers every padding a theme style sets. +#define WRAP_PADDING_SETTER(name) \ + void __real_##name(lv_style_t* style, int32_t value); \ + void __wrap_##name(lv_style_t* style, int32_t value) { __real_##name(style, scalePadding(value)); } + +WRAP_PADDING_SETTER(lv_style_set_pad_top) +WRAP_PADDING_SETTER(lv_style_set_pad_bottom) +WRAP_PADDING_SETTER(lv_style_set_pad_left) +WRAP_PADDING_SETTER(lv_style_set_pad_right) +WRAP_PADDING_SETTER(lv_style_set_pad_row) +WRAP_PADDING_SETTER(lv_style_set_pad_column) + +#undef WRAP_PADDING_SETTER + +} // extern "C" diff --git a/Devices/waveshare-s3-touch-amoled-18/source/module.cpp b/Devices/waveshare-s3-touch-amoled-18/source/module.cpp new file mode 100644 index 000000000..230d28ab3 --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/source/module.cpp @@ -0,0 +1,32 @@ +#include "bluetooth_reconnect.h" + +#include + +// Defined in expander_reset.cpp (C++ linkage). +void waveshare_amoled18_expander_reset(); + +extern "C" { + +static error_t start() { + // The panel/touch reset lines hang off the TCA9554 expander, so release them before the kernel + // starts the devicetree's display and touch devices. + waveshare_amoled18_expander_reset(); + waveshare_bluetooth_reconnect_start(); + return ERROR_NONE; +} + +static error_t stop() { + waveshare_bluetooth_reconnect_stop(); + return ERROR_NONE; +} + +Module waveshare_s3_touch_amoled_18_module = { + .name = "waveshare-s3-touch-amoled-18", + .start = start, + .stop = stop, + .drivers = nullptr, + .symbols = nullptr, + .internal = nullptr +}; + +} diff --git a/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts b/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts new file mode 100644 index 000000000..6ce526736 --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts @@ -0,0 +1,147 @@ +/dts-v1/; + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Reference: https://www.waveshare.com/wiki/ESP32-S3-Touch-AMOLED-1.8 +/ { + compatible = "root"; + model = "Waveshare S3 Touch AMOLED 1.8"; + + wifi0 { + compatible = "espressif,esp32-wifi-pinned"; + }; + + ble0 { + compatible = "espressif,esp32-ble"; + }; + + gpio0 { + compatible = "espressif,esp32-gpio"; + gpio-count = <49>; + }; + + // ES8311 speaker amplifier enable, active-high on GPIO46. + amp_enable { + compatible = "gpio-hog"; + pin = <&gpio0 46 GPIO_FLAG_NONE>; + mode = ; + }; + + // i2s0 must be before i2c0 so the codec can reference it. + i2s0 { + compatible = "espressif,esp32-i2s"; + port = ; + pin-bclk = <&gpio0 9 GPIO_FLAG_NONE>; + pin-ws = <&gpio0 45 GPIO_FLAG_NONE>; + pin-data-out = <&gpio0 8 GPIO_FLAG_NONE>; + pin-data-in = <&gpio0 10 GPIO_FLAG_NONE>; + pin-mclk = <&gpio0 16 GPIO_FLAG_NONE>; + }; + + i2c0 { + compatible = "espressif,esp32-i2c-master"; + port = ; + clock-frequency = <400000>; + pin-sda = <&gpio0 15 GPIO_FLAG_PULL_UP>; + pin-scl = <&gpio0 14 GPIO_FLAG_PULL_UP>; + + qmi8658 { + compatible = "qst,qmi8658"; + reg = <0x6B>; + }; + + // AXP2101 PMIC. The board runs on the chip's own power-up rail defaults: Waveshare's + // BSP and examples never reconfigure the LDOs, so no rail overrides are set here. + axp2101: axp2101 { + compatible = "x-powers,axp2101"; + reg = <0x34>; + }; + + // FT3168, register-compatible with the FT5x06. There is no dedicated reset pin. + touch0 { + compatible = "focaltech,ft5x06"; + reg = <0x38>; + x-max = <368>; + y-max = <448>; + pin-interrupt = <&gpio0 21 GPIO_FLAG_NONE>; + disable-monitor-timeout; + }; + + es8311: es8311 { + compatible = "everest,es8311"; + reg = <0x18>; + i2s = <&i2s0>; + }; + + // TCA9554 8-bit I/O expander, register-compatible with the TCA9534 driver. + tca9554: tca9554 { + compatible = "ti,tca9534"; + reg = <0x20>; + }; + }; + + // AMOLED display on QSPI: CS=12, PCLK=11, D0..D3=4..7. No hardware reset pin. + spi2 { + compatible = "espressif,esp32-spi"; + host = ; + cs-gpios = <&gpio0 12 GPIO_FLAG_NONE>; + pin-mosi = <&gpio0 4 GPIO_FLAG_NONE>; + pin-miso = <&gpio0 5 GPIO_FLAG_NONE>; + pin-wp = <&gpio0 6 GPIO_FLAG_NONE>; + pin-hd = <&gpio0 7 GPIO_FLAG_NONE>; + pin-sclk = <&gpio0 11 GPIO_FLAG_NONE>; + max-transfer-size = <65536>; + + display0: display@0 { + compatible = "shenghe,sh8601"; + horizontal-resolution = <368>; + vertical-resolution = <448>; + gap-x = <0>; + gap-y = <0>; + pixel-clock-hz = <40000000>; + + // The SH8601 has no backlight pin; brightness is a panel command. The driver finds + // this child device via the display's children, and the backlight uses its parent. + display_backlight { + compatible = "sh8601-backlight"; + brightness-default = <255>; + }; + }; + }; + + // TF card on SDMMC (1-bit): CLK=2, CMD=1, D0=3. + sdmmc0 { + compatible = "espressif,esp32-sdmmc"; + pin-clk = <&gpio0 2 GPIO_FLAG_NONE>; + pin-cmd = <&gpio0 1 GPIO_FLAG_NONE>; + pin-d0 = <&gpio0 3 GPIO_FLAG_NONE>; + slot = ; + bus-width = <1>; + max-freq-khz = <20000>; + pullups; + }; + + usbdevice0 { + compatible = "espressif,esp32-usbdevice"; + + usbdevicemsc0 { + compatible = "espressif,esp32-usbdevice-msc"; + }; + }; +}; From 2378b0ea61c044982fb7be70e6342c32d17695a9 Mon Sep 17 00:00:00 2001 From: Loelin Date: Sun, 11 Oct 2026 14:04:53 -0400 Subject: [PATCH 6/9] Add PCF85063A RTC driver; declare it on the 1.8 board The Waveshare 1.8" board carries a PCF85063A RTC at 0x51, not the 2.06"s BM8563. The two share an address but not their register maps: the PCF85063 keeps seconds..years at 0x04-0x0A with the OS integrity flag in bit 7 of the seconds register, so the bm8563 driver (0x02-0x08) cannot drive it. Add Drivers/pcf85063-module, modelled on bm8563-module (RtcApi / RTC_TYPE, control/register8 helpers) but with the PCF85063 layout, and declare the chip at 0x51 in the 1.8 devicetree. The I2C scanner now labels 0x51 as "pcf85063" instead of leaving the row unidentified, and the board gains RTC support. --- .../waveshare-s3-touch-amoled-18/module.yaml | 1 + .../waveshare,s3-touch-amoled-18.dts | 8 + Drivers/pcf85063-module/CMakeLists.txt | 11 + Drivers/pcf85063-module/LICENSE-Apache-2.0.md | 195 ++++++++++++++++++ Drivers/pcf85063-module/README.md | 12 ++ .../bindings/nxp,pcf85063a.yaml | 5 + .../include/bindings/pcf85063.h | 15 ++ .../include/drivers/pcf85063.h | 37 ++++ .../pcf85063-module/include/pcf85063_module.h | 14 ++ Drivers/pcf85063-module/module.yaml | 3 + Drivers/pcf85063-module/source/module.cpp | 23 +++ Drivers/pcf85063-module/source/pcf85063.cpp | 125 +++++++++++ 12 files changed, 449 insertions(+) create mode 100644 Drivers/pcf85063-module/CMakeLists.txt create mode 100644 Drivers/pcf85063-module/LICENSE-Apache-2.0.md create mode 100644 Drivers/pcf85063-module/README.md create mode 100644 Drivers/pcf85063-module/bindings/nxp,pcf85063a.yaml create mode 100644 Drivers/pcf85063-module/include/bindings/pcf85063.h create mode 100644 Drivers/pcf85063-module/include/drivers/pcf85063.h create mode 100644 Drivers/pcf85063-module/include/pcf85063_module.h create mode 100644 Drivers/pcf85063-module/module.yaml create mode 100644 Drivers/pcf85063-module/source/module.cpp create mode 100644 Drivers/pcf85063-module/source/pcf85063.cpp diff --git a/Devices/waveshare-s3-touch-amoled-18/module.yaml b/Devices/waveshare-s3-touch-amoled-18/module.yaml index 6fcf91f9e..3e39b4c7c 100644 --- a/Devices/waveshare-s3-touch-amoled-18/module.yaml +++ b/Devices/waveshare-s3-touch-amoled-18/module.yaml @@ -2,6 +2,7 @@ dependencies: - Platforms/platform-esp32 - Drivers/sh8601-module - Drivers/axp2101-module + - Drivers/pcf85063-module - Drivers/qmi8658-module - Drivers/ft5x06-module - Drivers/es8311-module diff --git a/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts b/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts index 6ce526736..c221bb99e 100644 --- a/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts +++ b/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -73,6 +74,13 @@ reg = <0x34>; }; + // PCF85063A RTC at 0x51. This board's RTC is not the 2.06's BM8563: the PCF85063 + // keeps its time registers at 0x04-0x0A (not 0x02-0x08), so it needs the pcf85063 driver. + pcf85063: pcf85063 { + compatible = "nxp,pcf85063a"; + reg = <0x51>; + }; + // FT3168, register-compatible with the FT5x06. There is no dedicated reset pin. touch0 { compatible = "focaltech,ft5x06"; diff --git a/Drivers/pcf85063-module/CMakeLists.txt b/Drivers/pcf85063-module/CMakeLists.txt new file mode 100644 index 000000000..2c82a68bd --- /dev/null +++ b/Drivers/pcf85063-module/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.20) + +include("${CMAKE_CURRENT_LIST_DIR}/../../Buildscripts/module.cmake") + +file(GLOB_RECURSE SOURCE_FILES "source/*.c*") + +tactility_add_module(pcf85063-module + SRCS ${SOURCE_FILES} + INCLUDE_DIRS include/ + REQUIRES TactilityKernel +) diff --git a/Drivers/pcf85063-module/LICENSE-Apache-2.0.md b/Drivers/pcf85063-module/LICENSE-Apache-2.0.md new file mode 100644 index 000000000..f5f4b8b5e --- /dev/null +++ b/Drivers/pcf85063-module/LICENSE-Apache-2.0.md @@ -0,0 +1,195 @@ +Apache License +============== + +_Version 2.0, January 2004_ +_<>_ + +### Terms and Conditions for use, reproduction, and distribution + +#### 1. Definitions + +“License” shall mean the terms and conditions for use, reproduction, and +distribution as defined by Sections 1 through 9 of this document. + +“Licensor” shall mean the copyright owner or entity authorized by the copyright +owner that is granting the License. + +“Legal Entity” shall mean the union of the acting entity and all other entities +that control, are controlled by, or are under common control with that entity. +For the purposes of this definition, “control” means **(i)** the power, direct or +indirect, to cause the direction or management of such entity, whether by +contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the +outstanding shares, or **(iii)** beneficial ownership of such entity. + +“You” (or “Your”) shall mean an individual or Legal Entity exercising +permissions granted by this License. + +“Source” form shall mean the preferred form for making modifications, including +but not limited to software source code, documentation source, and configuration +files. + +“Object” form shall mean any form resulting from mechanical transformation or +translation of a Source form, including but not limited to compiled object code, +generated documentation, and conversions to other media types. + +“Work” shall mean the work of authorship, whether in Source or Object form, made +available under the License, as indicated by a copyright notice that is included +in or attached to the work (an example is provided in the Appendix below). + +“Derivative Works” shall mean any work, whether in Source or Object form, that +is based on (or derived from) the Work and for which the editorial revisions, +annotations, elaborations, or other modifications represent, as a whole, an +original work of authorship. For the purposes of this License, Derivative Works +shall not include works that remain separable from, or merely link (or bind by +name) to the interfaces of, the Work and Derivative Works thereof. + +“Contribution” shall mean any work of authorship, including the original version +of the Work and any modifications or additions to that Work or Derivative Works +thereof, that is intentionally submitted to Licensor for inclusion in the Work +by the copyright owner or by an individual or Legal Entity authorized to submit +on behalf of the copyright owner. For the purposes of this definition, +“submitted” means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, and +issue tracking systems that are managed by, or on behalf of, the Licensor for +the purpose of discussing and improving the Work, but excluding communication +that is conspicuously marked or otherwise designated in writing by the copyright +owner as “Not a Contribution.” + +“Contributor” shall mean Licensor and any individual or Legal Entity on behalf +of whom a Contribution has been received by Licensor and subsequently +incorporated within the Work. + +#### 2. Grant of Copyright License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the Work and such +Derivative Works in Source or Object form. + +#### 3. Grant of Patent License + +Subject to the terms and conditions of this License, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this section) patent license to make, have +made, use, offer to sell, sell, import, and otherwise transfer the Work, where +such license applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by combination +of their Contribution(s) with the Work to which such Contribution(s) was +submitted. If You institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work or a +Contribution incorporated within the Work constitutes direct or contributory +patent infringement, then any patent licenses granted to You under this License +for that Work shall terminate as of the date such litigation is filed. + +#### 4. Redistribution + +You may reproduce and distribute copies of the Work or Derivative Works thereof +in any medium, with or without modifications, and in Source or Object form, +provided that You meet the following conditions: + +* **(a)** You must give any other recipients of the Work or Derivative Works a copy of +this License; and +* **(b)** You must cause any modified files to carry prominent notices stating that You +changed the files; and +* **(c)** You must retain, in the Source form of any Derivative Works that You distribute, +all copyright, patent, trademark, and attribution notices from the Source form +of the Work, excluding those notices that do not pertain to any part of the +Derivative Works; and +* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any +Derivative Works that You distribute must include a readable copy of the +attribution notices contained within such NOTICE file, excluding those notices +that do not pertain to any part of the Derivative Works, in at least one of the +following places: within a NOTICE text file distributed as part of the +Derivative Works; within the Source form or documentation, if provided along +with the Derivative Works; or, within a display generated by the Derivative +Works, if and wherever such third-party notices normally appear. The contents of +the NOTICE file are for informational purposes only and do not modify the +License. You may add Your own attribution notices within Derivative Works that +You distribute, alongside or as an addendum to the NOTICE text from the Work, +provided that such additional attribution notices cannot be construed as +modifying the License. + +You may add Your own copyright statement to Your modifications and may provide +additional or different license terms and conditions for use, reproduction, or +distribution of Your modifications, or for any such Derivative Works as a whole, +provided Your use, reproduction, and distribution of the Work otherwise complies +with the conditions stated in this License. + +#### 5. Submission of Contributions + +Unless You explicitly state otherwise, any Contribution intentionally submitted +for inclusion in the Work by You to the Licensor shall be under the terms and +conditions of this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify the terms of +any separate license agreement you may have executed with Licensor regarding +such Contributions. + +#### 6. Trademarks + +This License does not grant permission to use the trade names, trademarks, +service marks, or product names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the NOTICE file. + +#### 7. Disclaimer of Warranty + +Unless required by applicable law or agreed to in writing, Licensor provides the +Work (and each Contributor provides its Contributions) on an “AS IS” BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, +including, without limitation, any warranties or conditions of TITLE, +NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are +solely responsible for determining the appropriateness of using or +redistributing the Work and assume any risks associated with Your exercise of +permissions under this License. + +#### 8. Limitation of Liability + +In no event and under no legal theory, whether in tort (including negligence), +contract, or otherwise, unless required by applicable law (such as deliberate +and grossly negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, incidental, +or consequential damages of any character arising as a result of this License or +out of the use or inability to use the Work (including but not limited to +damages for loss of goodwill, work stoppage, computer failure or malfunction, or +any and all other commercial damages or losses), even if such Contributor has +been advised of the possibility of such damages. + +#### 9. Accepting Warranty or Additional Liability + +While redistributing the Work or Derivative Works thereof, You may choose to +offer, and charge a fee for, acceptance of support, warranty, indemnity, or +other liability obligations and/or rights consistent with this License. However, +in accepting such obligations, You may act only on Your own behalf and on Your +sole responsibility, not on behalf of any other Contributor, and only if You +agree to indemnify, defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason of your +accepting any such warranty or additional liability. + +_END OF TERMS AND CONDITIONS_ + +### APPENDIX: How to apply the Apache License to your work + +To apply the Apache License to your work, attach the following boilerplate +notice, with the fields enclosed by brackets `[]` replaced with your own +identifying information. (Don't include the brackets!) The text should be +enclosed in the appropriate comment syntax for the file format. We also +recommend that a file or class name and description of purpose be included on +the same “printed page” as the copyright notice for easier identification within +third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Drivers/pcf85063-module/README.md b/Drivers/pcf85063-module/README.md new file mode 100644 index 000000000..6cccf50e7 --- /dev/null +++ b/Drivers/pcf85063-module/README.md @@ -0,0 +1,12 @@ +# PCF85063A I2C Driver + +A driver for the `PCF85063A` Real-Time Clock/Calendar from NXP. + +Unlike the `BM8563`/`PCF8563` (which is what this repo's `bm8563-module` targets), the +PCF85063 keeps its time registers at `0x04`–`0x0A` (seconds, minutes, hours, days, +weekdays, months, years), with the `OS` clock-integrity flag in bit 7 of the seconds +register and the century flag in bit 7 of the months register. + +See https://www.nxp.com/docs/en/data-sheet/PCF85063A.pdf + +License: [Apache v2.0](LICENSE-Apache-2.0.md) diff --git a/Drivers/pcf85063-module/bindings/nxp,pcf85063a.yaml b/Drivers/pcf85063-module/bindings/nxp,pcf85063a.yaml new file mode 100644 index 000000000..2324ca60c --- /dev/null +++ b/Drivers/pcf85063-module/bindings/nxp,pcf85063a.yaml @@ -0,0 +1,5 @@ +description: PCF85063A RTC + +include: [ "i2c-device.yaml" ] + +compatible: "nxp,pcf85063a" diff --git a/Drivers/pcf85063-module/include/bindings/pcf85063.h b/Drivers/pcf85063-module/include/bindings/pcf85063.h new file mode 100644 index 000000000..50d50626f --- /dev/null +++ b/Drivers/pcf85063-module/include/bindings/pcf85063.h @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +DEFINE_DEVICETREE(pcf85063a, struct Pcf85063Config) + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/pcf85063-module/include/drivers/pcf85063.h b/Drivers/pcf85063-module/include/drivers/pcf85063.h new file mode 100644 index 000000000..13db8b88e --- /dev/null +++ b/Drivers/pcf85063-module/include/drivers/pcf85063.h @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include + +struct Device; + +#ifdef __cplusplus +extern "C" { +#endif + +struct Pcf85063Config { + /** Address on bus */ + uint8_t address; +}; + +/** + * Read the current date and time from the RTC. + * @param[in] device pcf85063 device + * @param[out] dt Pointer to RtcDateTime to populate + * @return ERROR_NONE on success + */ +error_t pcf85063_get_datetime(struct Device* device, struct RtcDateTime* dt); + +/** + * Write the date and time to the RTC. + * @param[in] device pcf85063 device + * @param[in] dt Pointer to RtcDateTime to write (year must be 2000–2199) + * @return ERROR_NONE on success, ERROR_INVALID_ARGUMENT if any field is out of range + */ +error_t pcf85063_set_datetime(struct Device* device, const struct RtcDateTime* dt); + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/pcf85063-module/include/pcf85063_module.h b/Drivers/pcf85063-module/include/pcf85063_module.h new file mode 100644 index 000000000..c933b3423 --- /dev/null +++ b/Drivers/pcf85063-module/include/pcf85063_module.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +extern struct Module pcf85063_module; + +#ifdef __cplusplus +} +#endif diff --git a/Drivers/pcf85063-module/module.yaml b/Drivers/pcf85063-module/module.yaml new file mode 100644 index 000000000..a07d6f334 --- /dev/null +++ b/Drivers/pcf85063-module/module.yaml @@ -0,0 +1,3 @@ +dependencies: + - TactilityKernel +bindings: bindings diff --git a/Drivers/pcf85063-module/source/module.cpp b/Drivers/pcf85063-module/source/module.cpp new file mode 100644 index 000000000..a9a7b53a3 --- /dev/null +++ b/Drivers/pcf85063-module/source/module.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +extern "C" { + +extern Driver pcf85063_driver; + +static Driver* const pcf85063_drivers[] = { + &pcf85063_driver, + nullptr +}; + +Module pcf85063_module = { + .name = "pcf85063", + .start = nullptr, + .stop = nullptr, + .drivers = pcf85063_drivers, + .symbols = nullptr, + .internal = nullptr +}; + +} diff --git a/Drivers/pcf85063-module/source/pcf85063.cpp b/Drivers/pcf85063-module/source/pcf85063.cpp new file mode 100644 index 000000000..9b52641a7 --- /dev/null +++ b/Drivers/pcf85063-module/source/pcf85063.cpp @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include +#include +#include + +#define TAG "PCF85063" + +static constexpr uint8_t REG_CTRL1 = 0x00; // Control_1 +static constexpr uint8_t CTRL1_STOP = 0x20; // STOP: 1 = oscillator stopped +static constexpr uint8_t REG_SECONDS = 0x04; // Seconds BCD, bit 7 = OS (clock integrity) +// Registers 0x04–0x0A: seconds, minutes, hours, days, weekdays, months, years + +static constexpr TickType_t I2C_TIMEOUT_TICKS = pdMS_TO_TICKS(50); + +#define GET_CONFIG(device) (static_cast((device)->config)) + +// region Helpers + +static uint8_t bcd_to_dec(uint8_t bcd) { return static_cast((bcd >> 4) * 10 + (bcd & 0x0F)); } +static uint8_t dec_to_bcd(uint8_t dec) { return static_cast(((dec / 10) << 4) | (dec % 10)); } + +// endregion + +// region Driver lifecycle + +static error_t start(Device* device) { + auto* i2c_controller = device_get_parent(device); + if (device_get_type(i2c_controller) != &I2C_CONTROLLER_TYPE) { + LOG_E(TAG, "Parent is not an I2C controller"); + return ERROR_RESOURCE; + } + + auto address = GET_CONFIG(device)->address; + + // Clear the STOP bit only, so the oscillator runs while the other Control_1 settings + // (e.g. the crystal load-capacitance select) keep whatever the board already configured. + if (i2c_controller_register8_reset_bits(i2c_controller, address, REG_CTRL1, CTRL1_STOP, I2C_TIMEOUT_TICKS) != ERROR_NONE) { + LOG_E(TAG, "Failed to clear STOP bit at 0x%02X", address); + return ERROR_RESOURCE; + } + + return ERROR_NONE; +} + +static error_t stop(Device* device) { + // The RTC keeps running on its backup supply; nothing to do. + return ERROR_NONE; +} + +// endregion + +extern "C" { + +error_t pcf85063_get_datetime(Device* device, RtcDateTime* dt) { + auto* i2c_controller = device_get_parent(device); + auto address = GET_CONFIG(device)->address; + + // Burst-read 7 registers starting at 0x04: + // [0]=seconds [1]=minutes [2]=hours [3]=days [4]=weekdays [5]=months [6]=years + uint8_t buf[7] = {}; + error_t error = i2c_controller_read_register(i2c_controller, address, REG_SECONDS, buf, sizeof(buf), I2C_TIMEOUT_TICKS); + if (error != ERROR_NONE) return error; + + if (buf[0] & 0x80u) { + LOG_E(TAG, "Clock integrity compromised (OS flag set) — data unreliable"); + return ERROR_INVALID_STATE; + } + dt->second = bcd_to_dec(buf[0] & 0x7Fu); // mask OS flag + dt->minute = bcd_to_dec(buf[1] & 0x7Fu); + dt->hour = bcd_to_dec(buf[2] & 0x3Fu); + dt->day = bcd_to_dec(buf[3] & 0x3Fu); + // buf[4] = weekday — ignored + dt->month = bcd_to_dec(buf[5] & 0x1Fu); + bool century = (buf[5] & 0x80u) != 0; + dt->year = static_cast(2000 + bcd_to_dec(buf[6]) + (century ? 100 : 0)); + + return ERROR_NONE; +} + +error_t pcf85063_set_datetime(Device* device, const RtcDateTime* dt) { + if (dt->year < 2000 || dt->year > 2199 || + dt->month < 1 || dt->month > 12 || + dt->day < 1 || dt->day > 31 || + dt->hour > 23 || dt->minute > 59 || dt->second > 59) { + return ERROR_INVALID_ARGUMENT; + } + + auto* i2c_controller = device_get_parent(device); + auto address = GET_CONFIG(device)->address; + + bool century = (dt->year >= 2100); + uint8_t y = static_cast(century ? dt->year - 2100 : dt->year - 2000); + + uint8_t buf[7] = {}; + buf[0] = dec_to_bcd(dt->second); + buf[1] = dec_to_bcd(dt->minute); + buf[2] = dec_to_bcd(dt->hour); + buf[3] = dec_to_bcd(dt->day); + buf[4] = 0; // weekday — leave as Sunday (unused) + buf[5] = static_cast(dec_to_bcd(dt->month) | (century ? 0x80u : 0x00u)); + buf[6] = dec_to_bcd(y); + + return i2c_controller_write_register(i2c_controller, address, REG_SECONDS, buf, sizeof(buf), I2C_TIMEOUT_TICKS); +} + +RtcApi pcf85063_rtc_api = { + .get_time = pcf85063_get_datetime, + .set_time = pcf85063_set_datetime +}; + +Driver pcf85063_driver = { + .name = "pcf85063", + .compatible = (const char*[]) { "nxp,pcf85063a", nullptr }, + .start_device = start, + .stop_device = stop, + .probe = nullptr, + .api = &pcf85063_rtc_api, + .device_type = &RTC_TYPE, + .owner = &pcf85063_module, + .internal = nullptr +}; + +} // extern "C" From 1fcb6b4851313ae9cdb0bf093aaab90c629d3a6a Mon Sep 17 00:00:00 2001 From: Loelin Date: Sun, 11 Oct 2026 14:20:03 -0400 Subject: [PATCH 7/9] waveshare-s3-touch-amoled-18: set UI insets to 22 px on all edges Use a single uniform inset (lv_obj_set_style_pad_all) instead of the 2.06-style 50 px horizontal / 30 px vertical split. --- .../waveshare-s3-touch-amoled-18/source/lvgl_insets.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Devices/waveshare-s3-touch-amoled-18/source/lvgl_insets.cpp b/Devices/waveshare-s3-touch-amoled-18/source/lvgl_insets.cpp index 3050f0975..60a39495a 100644 --- a/Devices/waveshare-s3-touch-amoled-18/source/lvgl_insets.cpp +++ b/Devices/waveshare-s3-touch-amoled-18/source/lvgl_insets.cpp @@ -23,9 +23,8 @@ namespace { -// Fixed insets, in pixels: horizontal on the left/right, vertical on the top/bottom. -constexpr int32_t INSET_HORIZONTAL_PX = 50; -constexpr int32_t INSET_VERTICAL_PX = 30; +// Fixed inset, in pixels, applied on all four edges. +constexpr int32_t INSET_PX = 22; // The callback passed to window_manager_configure(), invoked later to build the window tree. WindowManagerScreenInitFn original_screen_init = nullptr; @@ -35,8 +34,7 @@ WindowManagerScreenInitFn original_screen_init = nullptr; extern "C" { static lv_obj_t* wrapping_screen_init(lv_obj_t* root_widget) { - lv_obj_set_style_pad_hor(root_widget, INSET_HORIZONTAL_PX, LV_STATE_DEFAULT); - lv_obj_set_style_pad_ver(root_widget, INSET_VERTICAL_PX, LV_STATE_DEFAULT); + lv_obj_set_style_pad_all(root_widget, INSET_PX, LV_STATE_DEFAULT); return original_screen_init != nullptr ? original_screen_init(root_widget) : root_widget; } From 9c73e2ac0de55fcd6561c29aca085b278cac2e37 Mon Sep 17 00:00:00 2001 From: Loelin Date: Tue, 20 Oct 2026 16:12:11 -0400 Subject: [PATCH 8/9] pcf85063: drop removed Driver.owner field --- Drivers/ft5x06-module/source/ft5x06.cpp | 15 +- Drivers/pcf85063-module/source/pcf85063.cpp | 1 - Drivers/sh8601-module/source/sh8601.cpp | 11 +- .../private/bluetooth/ble_store_fs.h | 29 ++ .../source/drivers/bluetooth/ble_store_fs.cpp | 490 ++++++++++++++++++ .../source/drivers/bluetooth/esp32_ble.cpp | 18 +- 6 files changed, 546 insertions(+), 18 deletions(-) create mode 100644 Platforms/platform-esp32/private/bluetooth/ble_store_fs.h create mode 100644 Platforms/platform-esp32/source/drivers/bluetooth/ble_store_fs.cpp diff --git a/Drivers/ft5x06-module/source/ft5x06.cpp b/Drivers/ft5x06-module/source/ft5x06.cpp index 9d06c91c6..af6f9f7a7 100644 --- a/Drivers/ft5x06-module/source/ft5x06.cpp +++ b/Drivers/ft5x06-module/source/ft5x06.cpp @@ -24,6 +24,16 @@ #define TAG "FT5x06" #define GET_CONFIG(device) (static_cast((device)->config)) +// FT5x06/FT3168 registers (same values esp_lcd_touch_ft5x06.c uses). +static constexpr uint8_t REG_MONITOR_TIMEOUT = 0x87; // seconds before monitor mode; 0 = never +static constexpr uint8_t REG_POWER_MODE = 0xA5; // 0 = active, 1 = monitor, 2 = standby, 3 = hibernate +static constexpr uint8_t REG_STATE = 0xA7; +static constexpr uint8_t REG_ERROR = 0xA9; + +// The esp_lcd I2C touch IO defaults to 0 (= wait forever), so a stuck bus would block the LVGL +// task indefinitely. Bound it; 100 ms is far more than any single touch transaction needs. +static constexpr int TOUCH_IO_TIMEOUT_MS = 100; + struct Ft5x06Internal { esp_lcd_panel_io_handle_t io_handle; esp_lcd_touch_handle_t touch_handle; @@ -67,6 +77,7 @@ static error_t pulse_reset(GpioDescriptor* descriptor) { // so no bus probing is needed here. static esp_err_t create_io_handle(Device* parent, esp_lcd_panel_io_handle_t* out_handle) { esp_lcd_panel_io_i2c_config_t io_config = ESP_LCD_TOUCH_IO_I2C_FT5x06_CONFIG(); + io_config.transaction_timeout_ms = TOUCH_IO_TIMEOUT_MS; auto* parent_driver = device_get_driver(parent); if (driver_is_compatible(parent_driver, "espressif,esp32-i2c-master")) { @@ -159,7 +170,7 @@ static error_t start(Device* device) { if (config->disable_monitor_timeout) { // FT5x06: 0 = never auto-enter monitor mode. uint8_t disable_monitor = 0; - if (esp_lcd_panel_io_tx_param(internal->io_handle, 0x87, &disable_monitor, 1) != ESP_OK) { + if (esp_lcd_panel_io_tx_param(internal->io_handle, REG_MONITOR_TIMEOUT, &disable_monitor, 1) != ESP_OK) { LOG_W(TAG, "Failed to disable monitor timeout"); } @@ -168,7 +179,7 @@ static error_t start(Device* device) { // into - monitor mode, where it stops answering I2C until touched, which makes the periodic // poll fail. Force active so every read is answered. uint8_t power_mode_active = 0; - if (esp_lcd_panel_io_tx_param(internal->io_handle, 0xA5, &power_mode_active, 1) != ESP_OK) { + if (esp_lcd_panel_io_tx_param(internal->io_handle, REG_POWER_MODE, &power_mode_active, 1) != ESP_OK) { LOG_W(TAG, "Failed to set active power mode"); } } diff --git a/Drivers/pcf85063-module/source/pcf85063.cpp b/Drivers/pcf85063-module/source/pcf85063.cpp index 9b52641a7..77ab998c8 100644 --- a/Drivers/pcf85063-module/source/pcf85063.cpp +++ b/Drivers/pcf85063-module/source/pcf85063.cpp @@ -118,7 +118,6 @@ Driver pcf85063_driver = { .probe = nullptr, .api = &pcf85063_rtc_api, .device_type = &RTC_TYPE, - .owner = &pcf85063_module, .internal = nullptr }; diff --git a/Drivers/sh8601-module/source/sh8601.cpp b/Drivers/sh8601-module/source/sh8601.cpp index 4a40d12d4..249a5f1f4 100644 --- a/Drivers/sh8601-module/source/sh8601.cpp +++ b/Drivers/sh8601-module/source/sh8601.cpp @@ -178,11 +178,12 @@ static error_t start(Device* device) { .octal_mode = 0, .quad_mode = 1, .sio_mode = 0, - // The LVGL color buffer lives in PSRAM (see PREFER_EXTERNAL_RAM). Without this the SPI - // driver bounces every chunk through a freshly-allocated internal DMA buffer (up to the - // bus's 64KB max-transfer-size), which fails with "Failed to allocate priv TX buffer" - // once internal RAM is tight (e.g. during Wi-Fi). ESP32-S3 can DMA from PSRAM directly. - .psram_dma_direct = 1, + // The LVGL color buffer lives in PSRAM (see PREFER_EXTERNAL_RAM), but ESP32-S3 cannot + // DMA from PSRAM reliably: under PSRAM contention (e.g. Wi-Fi) the QSPI DMA underruns, + // the SPI driver flags the transfer as failed and the panel IO's transaction pool + // desyncs permanently, so the display stops updating. Bounce each chunk through an + // internal DMA buffer instead - the bus max-transfer-size keeps those buffers small. + .psram_dma_direct = 0, .lsb_first = 0, .cs_high_active = 0, }, diff --git a/Platforms/platform-esp32/private/bluetooth/ble_store_fs.h b/Platforms/platform-esp32/private/bluetooth/ble_store_fs.h new file mode 100644 index 000000000..550daf71a --- /dev/null +++ b/Platforms/platform-esp32/private/bluetooth/ble_store_fs.h @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#ifdef ESP_PLATFORM +#include +#endif + +#if defined(CONFIG_BT_NIMBLE_ENABLED) + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Installs the filesystem-backed NimBLE security store (bond + CCCD records). + * + * Replaces IDF's NVS-backed store/config implementation: the in-RAM database and the + * key-matching/iteration semantics are the same, but the records are persisted to a + * single file on the data partition (/data, FAT) instead of NVS. + * + * Must be called instead of ble_store_config_init(). + */ +void ble_store_fs_init(void); + +#ifdef __cplusplus +} +#endif + +#endif // CONFIG_BT_NIMBLE_ENABLED diff --git a/Platforms/platform-esp32/source/drivers/bluetooth/ble_store_fs.cpp b/Platforms/platform-esp32/source/drivers/bluetooth/ble_store_fs.cpp new file mode 100644 index 000000000..382da20d7 --- /dev/null +++ b/Platforms/platform-esp32/source/drivers/bluetooth/ble_store_fs.cpp @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Filesystem-backed NimBLE security store. +// +// NimBLE's stock store (store/config) keeps its records in RAM and persists them to +// NVS. A bond that goes missing in NVS makes the stack treat a bonded peer as unbonded, +// which fires BT_PAIR_RESULT_BOND_LOST and tears down the Tactility-side paired-device +// record - so BLE auto-reconnect permanently loses its target. This implementation keeps +// the same in-RAM database and the same key-matching/iteration semantics, but persists +// the records to one file on the data partition (/data, FAT) instead of NVS. +// +// Install with ble_store_fs_init() in place of ble_store_config_init(). Only the three +// ble_hs_cfg.store_*_cb callbacks are replaced; ble_store_read()/write()/delete() and +// ble_store_iterate() all dispatch through those, so they keep working unchanged. + +#include + +#if defined(CONFIG_BT_NIMBLE_ENABLED) + +// tactility/log.h must precede the NimBLE headers: NimBLE's log_common.h defines +// LOG_LEVEL_* as macros with the same names as this header's LogLevel enum. +#include +#include + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr auto* TAG = "BleStoreFs"; + +constexpr uint32_t STORE_MAGIC = 0x424c5346; // "BLSF" +constexpr uint32_t STORE_VERSION = 1; + +// Capacities mirror IDF's store/config (see esp_nimble_cfg.h). The "> 0 ? ... : 1" keeps +// the arrays well-formed on a target where a section is compiled out. +constexpr size_t MAX_BONDS = MYNEWT_VAL(BLE_STORE_MAX_BONDS) > 0 ? MYNEWT_VAL(BLE_STORE_MAX_BONDS) : 1; +constexpr size_t MAX_CCCDS = MYNEWT_VAL(BLE_STORE_MAX_CCCDS) > 0 ? MYNEWT_VAL(BLE_STORE_MAX_CCCDS) : 1; +constexpr size_t MAX_CSFCS = MYNEWT_VAL(BLE_STORE_MAX_CSFCS) > 0 ? MYNEWT_VAL(BLE_STORE_MAX_CSFCS) : 1; + +// Sections in file order. +constexpr size_t SECTION_COUNT = 6; + +struct Database { + size_t num_our_secs = 0; + ble_store_value_sec our_secs[MAX_BONDS] = {}; + size_t num_peer_secs = 0; + ble_store_value_sec peer_secs[MAX_BONDS] = {}; + size_t num_cccds = 0; + ble_store_value_cccd cccds[MAX_CCCDS] = {}; + size_t num_csfcs = 0; + ble_store_value_csfc csfcs[MAX_CSFCS] = {}; + size_t num_local_irks = 0; + ble_store_value_local_irk local_irks[MAX_BONDS] = {}; + size_t num_rpa_recs = 0; + ble_store_value_rpa_rec rpa_recs[MAX_BONDS] = {}; +}; + +// Only ever touched under the NimBLE host lock: ble_store_read()/write()/delete() take it +// around each callback, and ble_store_fs_init() runs before the host is up. +Database db; + +struct FileHeader { + uint32_t magic; + uint32_t version; + uint32_t sec_size; + uint32_t cccd_size; + uint32_t csfc_size; + uint32_t irk_size; + uint32_t rpa_size; + uint32_t counts[SECTION_COUNT]; // our_secs, peer_secs, cccds, csfcs, local_irks, rpa_recs +}; + +std::string storeDir() { + char data_path[FILE_MAX_PATH_STRING_LENGTH]; + if (paths_get_data_path(data_path, sizeof(data_path)) != ERROR_NONE) { + return {}; + } + return std::string(data_path) + "/bluetooth"; +} + +std::string storePath() { + const std::string dir = storeDir(); + return dir.empty() ? std::string() : dir + "/ble_store.bin"; +} + +// Creates each level; mkdir() failing with EEXIST means it is already there. +bool ensureStoreDir(const std::string& dir) { + const size_t separator = dir.rfind('/'); + if (separator == std::string::npos) { + return false; + } + // The data path itself ("/tactility") may not exist yet on a fresh device. + const std::string parent = dir.substr(0, separator); + ::mkdir(parent.c_str(), 0755); + if (::mkdir(dir.c_str(), 0755) != 0 && errno != EEXIST) { + LOG_E(TAG, "Failed to create directory %s", dir.c_str()); + return false; + } + return true; +} + +// ---- Generic array helpers ---- + +template +bool writeArray(FILE* file, const T* items, size_t count) { + return count == 0 || fwrite(items, sizeof(T), count, file) == count; +} + +template +bool readArray(FILE* file, T* items, size_t count) { + return count == 0 || fread(items, sizeof(T), count, file) == count; +} + +template +void removeAt(T* items, size_t& count, size_t index) { + if (index + 1 < count) { + std::memmove(&items[index], &items[index + 1], (count - index - 1) * sizeof(T)); + } + count--; +} + +size_t clampCount(uint32_t value, size_t max) { + return value > max ? max : value; +} + +// ---- Persistence ---- + +bool persist() { + const std::string dir = storeDir(); + if (!ensureStoreDir(dir)) { + return false; + } + + const std::string path = storePath(); + if (path.empty()) { + return false; + } + const std::string temp_path = path + ".tmp"; + + FILE* file = fopen(temp_path.c_str(), "wb"); + if (file == nullptr) { + LOG_E(TAG, "Failed to open %s for writing", temp_path.c_str()); + return false; + } + + FileHeader header = {}; + header.magic = STORE_MAGIC; + header.version = STORE_VERSION; + header.sec_size = sizeof(ble_store_value_sec); + header.cccd_size = sizeof(ble_store_value_cccd); + header.csfc_size = sizeof(ble_store_value_csfc); + header.irk_size = sizeof(ble_store_value_local_irk); + header.rpa_size = sizeof(ble_store_value_rpa_rec); + header.counts[0] = static_cast(db.num_our_secs); + header.counts[1] = static_cast(db.num_peer_secs); + header.counts[2] = static_cast(db.num_cccds); + header.counts[3] = static_cast(db.num_csfcs); + header.counts[4] = static_cast(db.num_local_irks); + header.counts[5] = static_cast(db.num_rpa_recs); + + bool ok = fwrite(&header, sizeof(header), 1, file) == 1; + ok = ok && writeArray(file, db.our_secs, db.num_our_secs); + ok = ok && writeArray(file, db.peer_secs, db.num_peer_secs); + ok = ok && writeArray(file, db.cccds, db.num_cccds); + ok = ok && writeArray(file, db.csfcs, db.num_csfcs); + ok = ok && writeArray(file, db.local_irks, db.num_local_irks); + ok = ok && writeArray(file, db.rpa_recs, db.num_rpa_recs); + + if (fclose(file) != 0) { + ok = false; + } + if (!ok) { + LOG_E(TAG, "Failed to write %s", temp_path.c_str()); + std::remove(temp_path.c_str()); + return false; + } + + // Temp file + rename: a power loss mid-write can never leave a half-written store. + if (std::rename(temp_path.c_str(), path.c_str()) != 0) { + LOG_E(TAG, "Failed to replace %s", path.c_str()); + std::remove(temp_path.c_str()); + return false; + } + return true; +} + +void load() { + db = Database {}; + + const std::string path = storePath(); + FILE* file = fopen(path.c_str(), "rb"); + if (file == nullptr) { + return; // first boot / nothing paired yet + } + + FileHeader header = {}; + bool ok = fread(&header, sizeof(header), 1, file) == 1; + ok = ok && header.magic == STORE_MAGIC && header.version == STORE_VERSION; + ok = ok && header.sec_size == sizeof(ble_store_value_sec); + ok = ok && header.cccd_size == sizeof(ble_store_value_cccd); + ok = ok && header.csfc_size == sizeof(ble_store_value_csfc); + ok = ok && header.irk_size == sizeof(ble_store_value_local_irk); + ok = ok && header.rpa_size == sizeof(ble_store_value_rpa_rec); + + if (ok) { + db.num_our_secs = clampCount(header.counts[0], MAX_BONDS); + db.num_peer_secs = clampCount(header.counts[1], MAX_BONDS); + db.num_cccds = clampCount(header.counts[2], MAX_CCCDS); + db.num_csfcs = clampCount(header.counts[3], MAX_CSFCS); + db.num_local_irks = clampCount(header.counts[4], MAX_BONDS); + db.num_rpa_recs = clampCount(header.counts[5], MAX_BONDS); + + ok = readArray(file, db.our_secs, db.num_our_secs); + ok = ok && readArray(file, db.peer_secs, db.num_peer_secs); + ok = ok && readArray(file, db.cccds, db.num_cccds); + ok = ok && readArray(file, db.csfcs, db.num_csfcs); + ok = ok && readArray(file, db.local_irks, db.num_local_irks); + ok = ok && readArray(file, db.rpa_recs, db.num_rpa_recs); + } + + fclose(file); + + if (!ok) { + LOG_W(TAG, "Store file %s is unreadable or from an incompatible build - starting empty", path.c_str()); + db = Database {}; + } +} + +// BLE_ADDR_ANY is a C compound literal (&(ble_addr_t){...}), which is an rvalue in C++, so +// C++ code cannot take its address. Use a real object with the same value instead. +const ble_addr_t ANY_ADDR = { BLE_ADDR_PUBLIC, { 0, 0, 0, 0, 0, 0 } }; + +// ---- Key matching (mirrors store/config's ble_store_config_find_*) ---- +// +// read() must honour key.idx by skipping that many matches, because ble_store_iterate() +// walks a type by bumping idx from 0 until read() returns ENOENT. + +int findSec(const ble_store_key_sec& key, const ble_store_value_sec* values, size_t count) { + int skipped = 0; + for (size_t i = 0; i < count; i++) { + if (ble_addr_cmp(&key.peer_addr, &ANY_ADDR) != 0 && + ble_addr_cmp(&values[i].peer_addr, &key.peer_addr) != 0) { + continue; + } + if (key.idx > skipped) { + skipped++; + continue; + } + return static_cast(i); + } + return -1; +} + +int findCccd(const ble_store_key_cccd& key, const ble_store_value_cccd* values, size_t count) { + int skipped = 0; + for (size_t i = 0; i < count; i++) { + if (ble_addr_cmp(&key.peer_addr, &ANY_ADDR) != 0 && + ble_addr_cmp(&values[i].peer_addr, &key.peer_addr) != 0) { + continue; + } + if (key.chr_val_handle != 0 && values[i].chr_val_handle != key.chr_val_handle) { + continue; + } + if (key.idx > skipped) { + skipped++; + continue; + } + return static_cast(i); + } + return -1; +} + +int findCsfc(const ble_store_key_csfc& key, const ble_store_value_csfc* values, size_t count) { + if (ble_addr_cmp(&key.peer_addr, &ANY_ADDR) != 0) { + for (size_t i = 0; i < count; i++) { + if (ble_addr_cmp(&values[i].peer_addr, &key.peer_addr) == 0) { + return static_cast(i); + } + } + } else if (key.idx < count) { + // Enumerate: idx is used as a direct index when the peer is unspecified. + return static_cast(key.idx); + } + return -1; +} + +int findLocalIrk(const ble_store_key_local_irk& key, const ble_store_value_local_irk* values, size_t count) { + int skipped = 0; + for (size_t i = 0; i < count; i++) { + if (ble_addr_cmp(&key.addr, &ANY_ADDR) != 0 && + ble_addr_cmp(&values[i].addr, &key.addr) != 0) { + continue; + } + if (key.idx > skipped) { + skipped++; + continue; + } + return static_cast(i); + } + return -1; +} + +int findRpaRec(const ble_store_key_rpa_rec& key, const ble_store_value_rpa_rec* values, size_t count) { + int skipped = 0; + for (size_t i = 0; i < count; i++) { + // Matches when the key names either the RPA or the resolved identity address. + if (ble_addr_cmp(&values[i].peer_rpa_addr, &key.peer_rpa_addr) != 0 && + ble_addr_cmp(&values[i].peer_addr, &key.peer_rpa_addr) != 0) { + continue; + } + if (key.idx > skipped) { + skipped++; + continue; + } + return static_cast(i); + } + return -1; +} + +// ---- Write / erase primitives ---- + +template +int upsert(T* values, size_t& count, size_t max, int index, const T& value) { + if (index < 0) { + if (count >= max) { + return BLE_HS_ESTORE_CAP; + } + index = static_cast(count); + count++; + } + values[index] = value; + return persist() ? 0 : BLE_HS_ESTORE_FAIL; +} + +template +int erase(T* values, size_t& count, int index) { + if (index < 0) { + return BLE_HS_ENOENT; + } + removeAt(values, count, static_cast(index)); + return persist() ? 0 : BLE_HS_ESTORE_FAIL; +} + +// ---- Store backend ---- + +int fsRead(int obj_type, const union ble_store_key* key, union ble_store_value* value) { + switch (obj_type) { + case BLE_STORE_OBJ_TYPE_PEER_SEC: { + int index = findSec(key->sec, db.peer_secs, db.num_peer_secs); + if (index < 0) return BLE_HS_ENOENT; + value->sec = db.peer_secs[index]; + return 0; + } + case BLE_STORE_OBJ_TYPE_OUR_SEC: { + int index = findSec(key->sec, db.our_secs, db.num_our_secs); + if (index < 0) return BLE_HS_ENOENT; + value->sec = db.our_secs[index]; + return 0; + } + case BLE_STORE_OBJ_TYPE_CCCD: { + int index = findCccd(key->cccd, db.cccds, db.num_cccds); + if (index < 0) return BLE_HS_ENOENT; + value->cccd = db.cccds[index]; + return 0; + } + case BLE_STORE_OBJ_TYPE_CSFC: { + int index = findCsfc(key->csfc, db.csfcs, db.num_csfcs); + if (index < 0) return BLE_HS_ENOENT; + value->csfc = db.csfcs[index]; + return 0; + } + case BLE_STORE_OBJ_TYPE_PEER_ADDR: { + int index = findRpaRec(key->rpa_rec, db.rpa_recs, db.num_rpa_recs); + if (index < 0) return BLE_HS_ENOENT; + value->rpa_rec = db.rpa_recs[index]; + return 0; + } + case BLE_STORE_OBJ_TYPE_LOCAL_IRK: { + int index = findLocalIrk(key->local_irk, db.local_irks, db.num_local_irks); + if (index < 0) return BLE_HS_ENOENT; + value->local_irk = db.local_irks[index]; + return 0; + } + default: + return BLE_HS_ENOTSUP; + } +} + +int fsWrite(int obj_type, const union ble_store_value* val) { + switch (obj_type) { + case BLE_STORE_OBJ_TYPE_PEER_SEC: { + ble_store_key_sec key; + ble_store_key_from_value_sec(&key, &val->sec); + return upsert(db.peer_secs, db.num_peer_secs, MAX_BONDS, + findSec(key, db.peer_secs, db.num_peer_secs), val->sec); + } + case BLE_STORE_OBJ_TYPE_OUR_SEC: { + ble_store_key_sec key; + ble_store_key_from_value_sec(&key, &val->sec); + return upsert(db.our_secs, db.num_our_secs, MAX_BONDS, + findSec(key, db.our_secs, db.num_our_secs), val->sec); + } + case BLE_STORE_OBJ_TYPE_CCCD: { + ble_store_key_cccd key; + ble_store_key_from_value_cccd(&key, &val->cccd); + return upsert(db.cccds, db.num_cccds, MAX_CCCDS, + findCccd(key, db.cccds, db.num_cccds), val->cccd); + } + case BLE_STORE_OBJ_TYPE_CSFC: { + ble_store_key_csfc key; + ble_store_key_from_value_csfc(&key, &val->csfc); + return upsert(db.csfcs, db.num_csfcs, MAX_CSFCS, + findCsfc(key, db.csfcs, db.num_csfcs), val->csfc); + } + case BLE_STORE_OBJ_TYPE_PEER_ADDR: { + ble_store_key_rpa_rec key; + ble_store_key_from_value_rpa_rec(&key, &val->rpa_rec); + return upsert(db.rpa_recs, db.num_rpa_recs, MAX_BONDS, + findRpaRec(key, db.rpa_recs, db.num_rpa_recs), val->rpa_rec); + } + case BLE_STORE_OBJ_TYPE_LOCAL_IRK: { + ble_store_key_local_irk key; + ble_store_key_from_value_local_irk(&key, &val->local_irk); + return upsert(db.local_irks, db.num_local_irks, MAX_BONDS, + findLocalIrk(key, db.local_irks, db.num_local_irks), val->local_irk); + } + default: + return BLE_HS_ENOTSUP; + } +} + +int fsDelete(int obj_type, const union ble_store_key* key) { + switch (obj_type) { + case BLE_STORE_OBJ_TYPE_PEER_SEC: + return erase(db.peer_secs, db.num_peer_secs, findSec(key->sec, db.peer_secs, db.num_peer_secs)); + case BLE_STORE_OBJ_TYPE_OUR_SEC: + return erase(db.our_secs, db.num_our_secs, findSec(key->sec, db.our_secs, db.num_our_secs)); + case BLE_STORE_OBJ_TYPE_CCCD: + return erase(db.cccds, db.num_cccds, findCccd(key->cccd, db.cccds, db.num_cccds)); + case BLE_STORE_OBJ_TYPE_CSFC: + return erase(db.csfcs, db.num_csfcs, findCsfc(key->csfc, db.csfcs, db.num_csfcs)); + case BLE_STORE_OBJ_TYPE_PEER_ADDR: + return erase(db.rpa_recs, db.num_rpa_recs, findRpaRec(key->rpa_rec, db.rpa_recs, db.num_rpa_recs)); + case BLE_STORE_OBJ_TYPE_LOCAL_IRK: + return erase(db.local_irks, db.num_local_irks, findLocalIrk(key->local_irk, db.local_irks, db.num_local_irks)); + default: + return BLE_HS_ENOTSUP; + } +} + +} // namespace + +extern "C" { + +static int ble_store_fs_read(int obj_type, const union ble_store_key* key, union ble_store_value* value) { + return fsRead(obj_type, key, value); +} + +static int ble_store_fs_write(int obj_type, const union ble_store_value* val) { + return fsWrite(obj_type, val); +} + +static int ble_store_fs_delete(int obj_type, const union ble_store_key* key) { + return fsDelete(obj_type, key); +} + +void ble_store_fs_init(void) { + load(); + + ble_hs_cfg.store_read_cb = ble_store_fs_read; + ble_hs_cfg.store_write_cb = ble_store_fs_write; + ble_hs_cfg.store_delete_cb = ble_store_fs_delete; + + LOG_I(TAG, "Installed filesystem store (%s)", storePath().c_str()); +} + +} // extern "C" + +#endif // CONFIG_BT_NIMBLE_ENABLED diff --git a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble.cpp b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble.cpp index f9295d433..05d937298 100644 --- a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble.cpp +++ b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble.cpp @@ -20,7 +20,7 @@ #include #include #include -#include +#include #include #include @@ -37,9 +37,6 @@ extern "C" { } #endif -// ble_store_config_init() is not declared in the public header in some IDF versions. -extern "C" void ble_store_config_init(void); - // Sub-module API structs (defined in their respective cpp files). extern const BtHidDeviceApi nimble_hid_device_api; extern const BtSerialApi nimble_serial_api; @@ -743,14 +740,15 @@ static void dispatch_enable(BleCtx* ctx) { ble_hs_cfg.sm_our_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID; ble_hs_cfg.sm_their_key_dist = BLE_SM_PAIR_KEY_DIST_ENC | BLE_SM_PAIR_KEY_DIST_ID; - ble_store_config_init(); - ble_hs_cfg.store_read_cb = ble_store_config_read; - ble_hs_cfg.store_write_cb = ble_store_config_write; - ble_hs_cfg.store_delete_cb = ble_store_config_delete; + // Filesystem-backed bond/CCCD store on the data partition, instead of IDF's NVS one. + // A NVS bond that goes missing makes NimBLE treat a bonded peer as unbonded + // (BT_PAIR_RESULT_BOND_LOST), which drops the peer from the Tactility-side + // paired-device record and breaks BLE auto-reconnect. See ble_store_fs.cpp. + ble_store_fs_init(); - // Clear any stale "value_changed" flags left in NVS CCCD records. + // Clear any stale "value_changed" flags left in the stored CCCD records. // Old firmware called ble_svc_gatt_changed(0, 0xFFFF) from switchGattProfile(), - // which sets value_changed=1 in NVS for all bonded-but-disconnected peers. + // which sets value_changed=1 for all bonded-but-disconnected peers. // On reconnect NimBLE sends a Service Changed indication; Windows disconnects // without ACKing (it re-discovers instead), so NimBLE never clears the flag, // causing an infinite reconnect loop. Purge all value_changed flags on every From ccfcfca314db197a4cfc2de42a6667806d59b69c Mon Sep 17 00:00:00 2001 From: Loelin Date: Tue, 20 Oct 2026 16:12:11 -0400 Subject: [PATCH 9/9] waveshare-s3-touch-amoled-18: LCD/touch/BLE reliability fixes and bus tuning - SH8601: use internal DMA bounce buffers instead of PSRAM-direct QSPI DMA - FT5x06: correct register constants and raise I2C transaction timeout - ESP32 BLE: persist bonds in a filesystem-backed store instead of NVS - 1.8 devicetree: I2C0 at 100 kHz, SPI2 max transfer size 8 KB --- .../waveshare,s3-touch-amoled-18.dts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts b/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts index c221bb99e..d65906f8c 100644 --- a/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts +++ b/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts @@ -58,7 +58,7 @@ i2c0 { compatible = "espressif,esp32-i2c-master"; port = ; - clock-frequency = <400000>; + clock-frequency = <100000>; pin-sda = <&gpio0 15 GPIO_FLAG_PULL_UP>; pin-scl = <&gpio0 14 GPIO_FLAG_PULL_UP>; @@ -114,7 +114,7 @@ pin-wp = <&gpio0 6 GPIO_FLAG_NONE>; pin-hd = <&gpio0 7 GPIO_FLAG_NONE>; pin-sclk = <&gpio0 11 GPIO_FLAG_NONE>; - max-transfer-size = <65536>; + max-transfer-size = <8192>; display0: display@0 { compatible = "shenghe,sh8601";