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...
-
-
-
- - -
- - - - 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-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..3e39b4c7c --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/module.yaml @@ -0,0 +1,11 @@ +dependencies: + - Platforms/platform-esp32 + - Drivers/sh8601-module + - Drivers/axp2101-module + - Drivers/pcf85063-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..60a39495a --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/source/lvgl_insets.cpp @@ -0,0 +1,48 @@ +// 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 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; + +} // namespace + +extern "C" { + +static lv_obj_t* wrapping_screen_init(lv_obj_t* root_widget) { + 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; +} + +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..d65906f8c --- /dev/null +++ b/Devices/waveshare-s3-touch-amoled-18/waveshare,s3-touch-amoled-18.dts @@ -0,0 +1,155 @@ +/dts-v1/; + +#include +#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 = <100000>; + 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>; + }; + + // 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"; + 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 = <8192>; + + 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"; + }; + }; +}; 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 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/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/mock-drivers-module/source/mock_wifi.cpp b/Drivers/mock-drivers-module/source/mock_wifi.cpp index 81b693f64..6d3214ba9 100644 --- a/Drivers/mock-drivers-module/source/mock_wifi.cpp +++ b/Drivers/mock-drivers-module/source/mock_wifi.cpp @@ -37,6 +37,8 @@ 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"; +constexpr uint8_t MOCK_ACCESS_POINT_MAX_CONNECTIONS = 15; struct MockWifiCtx { Device* device = nullptr; @@ -44,6 +46,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 +94,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 || config->max_connections > MOCK_ACCESS_POINT_MAX_CONNECTIONS) { + 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 +417,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 +445,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/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..77ab998c8 --- /dev/null +++ b/Drivers/pcf85063-module/source/pcf85063.cpp @@ -0,0 +1,124 @@ +// 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, + .internal = nullptr +}; + +} // extern "C" 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/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/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 diff --git a/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp b/Platforms/platform-esp32/source/drivers/esp32_wifi.cpp index cd1234e29..6b1a55f92 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{}; @@ -70,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{}; @@ -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,147 @@ 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 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); + } + 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 || config->max_connections > ESP_WIFI_MAX_CONN_NUM) { + 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"); + 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"); + destroy_access_point_netif(ctx); + return ERROR_RESOURCE; + } + + wifi_config_t wifi_config {}; + // 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; + 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 +759,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 +785,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/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); -} - -} diff --git a/TactilityKernel/include/tactility/drivers/wifi.h b/TactilityKernel/include/tactility/drivers/wifi.h index 114b4a494..3f4866365 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[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 limit depends on the platform: 15 on the ESP32 family, 4 on the ESP32-C2. */ + 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, 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 + */ + 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); +}